diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..81dbe331 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,94 @@ +name: Bug report +description: Something isn't working as expected +title: '[Bug]: ' +labels: ['bug'] +body: + - type: markdown + attributes: + value: | + Before opening a bug report, please check that the issue hasn't already been reported. + + - type: textarea + id: description + attributes: + label: Describe the bug + description: A clear and concise description of what the bug is. + validations: + required: true + + - type: textarea + id: reproduce + attributes: + label: Minimal reproduction + description: A minimal code snippet or repository that reproduces the issue. + placeholder: | + import { createPermix } from 'permix' + + const permix = createPermix<{ post: ['read'] }>() + permix.setup({ post: { read: true } }) + permix.check('post.read') + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What did you expect to happen? + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Actual behavior + description: What actually happened? Include any error messages or console output. + validations: + required: true + + - type: input + id: version + attributes: + label: permix version + placeholder: e.g. 4.1.2 + validations: + required: true + + - type: dropdown + id: framework + attributes: + label: Framework / adapter + options: + - Core (no adapter) + - React + - Vue + - Solid + - Svelte + - Next.js + - TanStack Start + - Express + - Hono + - Fastify + - tRPC + - oRPC + - Elysia + - Node + - Other + validations: + required: true + + - type: input + id: framework_version + attributes: + label: Framework version + placeholder: e.g. React 19.2, Next.js 16 + validations: + required: false + + - type: checkboxes + id: checklist + attributes: + label: Checklist + options: + - label: I searched existing issues first + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..203644b5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Documentation + url: https://permix.letstri.dev/docs + about: Usage questions are often answered in the docs. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..5a0fe5b6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,41 @@ +name: Feature request +description: Propose a DX, API, or adapter improvement +title: '[Feature]: ' +labels: ['enhancement'] +body: + - type: input + id: summary + attributes: + label: Summary + placeholder: Add a typed helper for checking several permissions at once + validations: + required: true + + - type: textarea + id: problem + attributes: + label: Problem + description: What workflow or maintenance pain does this solve? + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: Keep this focused on implementation shape, not product marketing. + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + + - type: checkboxes + id: checklist + attributes: + label: Checklist + options: + - label: I checked existing issues and docs first + required: true diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..489b5f19 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,28 @@ +version: 2 + +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + day: monday + time: '05:00' + timezone: UTC + open-pull-requests-limit: 10 + groups: + workspace-dependencies: + patterns: + - '*' + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + time: '05:30' + timezone: UTC + open-pull-requests-limit: 5 + groups: + github-actions: + patterns: + - '*' diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..51ac51a0 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,18 @@ +## Summary + +- [ ] closes # +- [ ] follow-up issues opened and linked + +## Testing + +- [ ] `pnpm format:check` +- [ ] `pnpm lint` +- [ ] `pnpm check-types` +- [ ] `pnpm test` +- [ ] `pnpm verify` + +## Checklist + +- [ ] docs updated where needed (`docs/content/docs/`, `README.md`, `CONTRIBUTING.md`) +- [ ] `permix/skills/` aligned when public API or integration patterns changed +- [ ] added or updated tests where it materially reduces regression risk diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 00000000..4eb61d04 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,22 @@ +changelog: + exclude: + labels: + - ignore-for-release + categories: + - title: Breaking Changes + labels: + - breaking + - title: New Features + labels: + - enhancement + - feature + - title: Bug Fixes + labels: + - bug + - fix + - title: Documentation + labels: + - documentation + - title: Other Changes + labels: + - '*' diff --git a/.github/scripts/select-react-catalog.mjs b/.github/scripts/select-react-catalog.mjs new file mode 100644 index 00000000..de5edb04 --- /dev/null +++ b/.github/scripts/select-react-catalog.mjs @@ -0,0 +1,17 @@ +import { readFileSync, writeFileSync } from 'node:fs' + +const react = process.env.REACT +const reactTypes = process.env.REACT_TYPES +const reactDomTypes = process.env.REACT_DOM_TYPES + +if (!react || !reactTypes || !reactDomTypes) { + throw new Error('REACT, REACT_TYPES, and REACT_DOM_TYPES must be set') +} + +const yaml = readFileSync('pnpm-workspace.yaml', 'utf-8') + .replace(/^( {2}react: ).+$/m, `$1${react}`) + .replace(/^( {2}react-dom: ).+$/m, `$1${react}`) + .replace(/^( {2}'@types\/react': ).+$/m, `$1${reactTypes}`) + .replace(/^( {2}'@types\/react-dom': ).+$/m, `$1${reactDomTypes}`) + +writeFileSync('pnpm-workspace.yaml', yaml) diff --git a/.github/workflows/bundle-size.yml b/.github/workflows/bundle-size.yml new file mode 100644 index 00000000..c83af8b6 --- /dev/null +++ b/.github/workflows/bundle-size.yml @@ -0,0 +1,34 @@ +name: Bundle Size + +on: + pull_request: + branches: + - main + +jobs: + bundle-size: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build package + run: pnpm --filter permix build + + - name: Enforce bundle-size budgets + run: pnpm --filter permix size:compare diff --git a/.github/workflows/lint-check.yml b/.github/workflows/lint-check.yml index 7e646a89..39fecf19 100644 --- a/.github/workflows/lint-check.yml +++ b/.github/workflows/lint-check.yml @@ -16,15 +16,14 @@ jobs: with: ref: ${{ github.event.pull_request.head.sha }} + - name: Setup pnpm + uses: pnpm/action-setup@v4 + - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 24 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 11.5.0 + cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile diff --git a/.github/workflows/next-integration.yml b/.github/workflows/next-integration.yml new file mode 100644 index 00000000..809c956d --- /dev/null +++ b/.github/workflows/next-integration.yml @@ -0,0 +1,36 @@ +name: Next Integration + +on: + pull_request: + types: [opened, synchronize] + branches: + - main + +jobs: + next-matrix: + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install Playwright Chromium + run: pnpm --filter @permix/next-integration exec playwright install chromium --with-deps + + - name: Build Permix and run Next fixtures + run: pnpm test:next diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 47318b17..822b573b 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -1,6 +1,8 @@ name: Build and publish on: + release: + types: [published] workflow_dispatch: permissions: @@ -15,7 +17,8 @@ jobs: - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: - node-version: latest + node-version: 24 + cache: pnpm registry-url: 'https://registry.npmjs.org' - run: pnpm i --frozen-lockfile - run: pnpm run lint diff --git a/.github/workflows/react-compatibility.yml b/.github/workflows/react-compatibility.yml new file mode 100644 index 00000000..e5bb4a70 --- /dev/null +++ b/.github/workflows/react-compatibility.yml @@ -0,0 +1,62 @@ +name: React Compatibility + +on: + pull_request: + types: [opened, synchronize] + branches: + - main + +jobs: + react-compatibility: + name: React ${{ matrix.react }} + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - react: '18.3.1' + react-types: '18.3.23' + react-dom-types: '18.3.7' + pin: true + - react: '19.2.6' + react-types: '19.2.15' + react-dom-types: '19.2.3' + pin: false + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Pin React catalog versions + if: matrix.pin + env: + REACT: ${{ matrix.react }} + REACT_TYPES: ${{ matrix.react-types }} + REACT_DOM_TYPES: ${{ matrix.react-dom-types }} + run: node .github/scripts/select-react-catalog.mjs + + - name: Install pinned Permix dependencies + if: matrix.pin + # Catalog pin is workspace-wide; docs/fumadocs still declare React 19 peers. + run: pnpm install --filter permix --no-frozen-lockfile --config.strictPeerDependencies=false + + - name: Install dependencies + if: ${{ !matrix.pin }} + run: pnpm install --frozen-lockfile + + - name: Test React adapter + run: pnpm --filter permix exec vitest run src/react + + - name: Build + run: pnpm --filter permix build diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 00000000..e6251c62 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,27 @@ +name: Release Please + +on: + push: + branches: + - main + +permissions: + contents: write + issues: write + pull-requests: write + +concurrency: + group: release-please-${{ github.ref }} + cancel-in-progress: false + +jobs: + release-please: + name: Release Please + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Run Release Please + uses: googleapis/release-please-action@v5 + with: + config-file: release-please-config.json + manifest-file: .release-please-manifest.json diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..e37732df --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,32 @@ +name: Test + +on: + pull_request: + types: [opened, synchronize] + branches: + - main + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Test + run: pnpm test diff --git a/.github/workflows/types-check.yml b/.github/workflows/types-check.yml index 0f555beb..c7a6c09f 100644 --- a/.github/workflows/types-check.yml +++ b/.github/workflows/types-check.yml @@ -16,21 +16,90 @@ jobs: with: ref: ${{ github.event.pull_request.head.sha }} + - name: Setup pnpm + uses: pnpm/action-setup@v4 + - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Check types + run: pnpm run check-types + + build-dist: + name: Build dist + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Setup pnpm uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 with: - version: 11.5.0 + node-version: 24 + cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Build - run: pnpm run build + run: pnpm --filter permix build - - name: Check types - run: pnpm run check-types + - name: Upload dist + uses: actions/upload-artifact@v4 + with: + name: permix-dist + path: permix/dist + + typescript-compatibility: + name: TypeScript ${{ matrix.version }} + needs: build-dist + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - version: '5.9.3' + script: type-check:compat:5.9 + - version: '6.0.2' + script: type-check:compat:6 + - version: '7.0.2' + script: type-check:compat:7 + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Download dist + uses: actions/download-artifact@v4 + with: + name: permix-dist + path: permix/dist + + - name: Check source and published package + run: pnpm --filter permix ${{ matrix.script }} diff --git a/.gitignore b/.gitignore index e812c3e6..ef182563 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,10 @@ node_modules .DS_Store .pnpm-store +.turbo +permix/test/next/.scratch +permix/test/next/playwright-report +permix/test/next/test-results +permix/benchmarks/.bundle-size +.agents +.claude diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100644 index 00000000..9ef41ae4 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1 @@ +pnpm commitlint --edit "$1" diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 00000000..be491988 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + "permix": "4.1.2" +} diff --git a/.vscode/settings.json b/.vscode/settings.json index fa404b73..0e37544a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -14,5 +14,8 @@ }, "[jsonc]": { "editor.defaultFormatter": "oxc.oxc-vscode" - } + }, + "js/ts.experimental.useTsgo": true, + "js/ts.tsdk.path": "./node_modules/typescript", + "typescript.preferences.preferTypeOnlyAutoImports": true } diff --git a/AGENTS.md b/AGENTS.md index bde723ed..e24bc51d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ Skills for teams **using** Permix ship in the published npm package at [`permix/ Repo-root [`_artifacts/`](_artifacts/skill_tree.yaml) (`domain_map.yaml`, `skill_spec.md`, `skill_tree.yaml`) tracks skill coverage and source-doc references for CI staleness checks. -When you change public API behavior, docs examples, or integration patterns, keep `permix/skills/` aligned with `docs/content/docs/` and `examples/`, bump `library_version` in SKILL frontmatter on release, and run `cd permix && pnpm run skills:stale`. +When you change public API behavior, docs examples, or integration patterns, keep `permix/skills/` aligned with `docs/content/docs/` and `examples/`, then run `cd permix && pnpm run skills:stale`. Release Please bumps `library_version` in SKILL frontmatter on release. ## Repository layout @@ -33,13 +33,19 @@ From repo root (pnpm workspace: `permix`, `docs`, `examples/*`): ```bash pnpm install +pnpm verify # format, lint, test, types, build pnpm test && pnpm run check-types pnpm run lint pnpm run format pnpm run format:check cd permix && pnpm run build +cd permix && pnpm run size:compare # run after build cd docs && pnpm dev # http://localhost:3000 cd docs && pnpm types:check # fumadocs-mdx + tsc for docs only ``` +Bundle-size fixtures, budgets, and the committed baseline live in [`permix/benchmarks/`](permix/benchmarks/README.md). Every public export and the extractor CLI must remain covered. Do not raise budgets or refresh the baseline solely to make CI pass; inspect the generated bundle, document an intentional increase, and keep browser fixtures free of extractor dependencies. + +Use [Conventional Commits](https://www.conventionalcommits.org/). Husky runs commitlint on `commit-msg`. Release Please on `main` opens a release PR that bumps `permix`, updates [CHANGELOG.md](CHANGELOG.md), and tags `vMAJOR.MINOR.PATCH`. Merging that PR publishes a GitHub Release; npm publish runs from `.github/workflows/npm-publish.yml`. The docs changelog page (`/docs/changelog`) inlines repo-root `CHANGELOG.md` at compile time. + Do not commit unless the user asks. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..a2916b1d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,984 @@ +# Changelog + +All notable changes to `permix` are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Entries through 4.1.2 were reconstructed from npm publish dates and git history; later versions are maintained by Release Please. + +## [4.1.2](https://github.com/letstri/permix/compare/v4.1.1...v4.1.2) (2026-07-02) + +### Bug Fixes + +- fix: tanstack start docs + +### Documentation + +- docs: improve for agents +- docs: fix llms link + +### Miscellaneous + +- refactor: update skills +- chore: add claude launch +- updates +- add tanstack/intent + +## [4.1.1](https://github.com/letstri/permix/compare/v4.1.0...v4.1.1) (2026-06-11) + +### Miscellaneous + +- update skills +- fixes +- updates + +## [4.1.0](https://github.com/letstri/permix/compare/v4.0.1...v4.1.0) (2026-06-08) + +### Bug Fixes + +- fix: change jsx in tsconfig to react-jsx + +### Miscellaneous + +- fix trpc types +- add tanstack start example +- refactor: change symbol to plain string +- update docs +- add new 'check' hook and add hooks to each integration +- updates + +## [4.0.1](https://github.com/letstri/permix/compare/v4.0.0...v4.0.1) (2026-06-03) + +### Miscellaneous + +- updates +- updates +- updates +- updates +- updates +- add workflows +- update docs +- minor +- updates +- remove better auth +- update comments + +## [4.0.0](https://github.com/letstri/permix/compare/v3.8.1...v4.0.0) (2026-06-03) + +### Miscellaneous + +- update packages +- updates +- updates +- fixes +- fixes +- updates +- add svelte +- rename typeRequired to required +- fix doc +- updates +- add better auth +- remove commitlint +- chore: add drizzle +- v4 +- chore: add zed config +- chore: add imports + +## [3.8.1](https://github.com/letstri/permix/compare/v3.8.0...v3.8.1) (2026-04-29) + +### Bug Fixes + +- fix(elysia): relax checkHandler context type to support schema validation + +## [3.8.0](https://github.com/letstri/permix/compare/v3.7.0...v3.8.0) (2026-03-23) + +### Features + +- feat(better-auth): export PermixSession interface + +### Miscellaneous + +- feat(better-auth)!: redesign API — split permixPlugin, createPermix, permixClient +- refactor: update publish workflow + +## [3.7.0](https://github.com/letstri/permix/compare/v3.6.0...v3.7.0) (2026-03-19) + +### Features + +- feat(better-auth): add server and client plugins for Better Auth integration +- feat(orpc): allow customizing the context key for the permix instance + +### Documentation + +- docs(better-auth): add integration docs page +- docs: fix permix check function + +### Miscellaneous + +- refactor: improve better auth types +- chore(better-auth): add build entry, subpath export, and peer dependency +- test(better-auth): add tests for Better Auth plugin +- chore: update packages +- chore: change fumadocs theme +- chore: update packages +- refactor: move all packages to workspace +- chore: remove context7 +- chore: add context7 + +## [3.6.0](https://github.com/letstri/permix/compare/v3.5.2...v3.6.0) (2025-11-06) + +### Features + +- feat: add "any" keyword for permissions checking + +## [3.5.2](https://github.com/letstri/permix/compare/v3.5.1...v3.5.2) (2025-11-04) + +### Bug Fixes + +- fix: add react-dom ad optional peer +- fix: markdown url 404 by proxying to github raw files + +### Documentation + +- docs: fix build +- docs: update packages +- docs: update fumadocs +- docs: restore twoslash +- docs: fix llms +- docs(refactor): added new toc style in docs + +### Miscellaneous + +- chore: update packages + +## [3.5.1](https://github.com/letstri/permix/compare/v3.5.0...v3.5.1) (2025-11-02) + +### Miscellaneous + +- chore: update packages +- chore: update packages +- refactor: convert script from js to ts +- chore: bump node version +- refactor: add template to trpc +- refactor: add more tests +- chore: update packages + +## [3.5.0](https://github.com/letstri/permix/compare/v3.4.1...v3.5.0) (2025-08-02) + +### Features + +- feat: add dehydrate and hydrate to permix instance + +### Documentation + +- docs: update hydration to new api + +## [3.4.1](https://github.com/letstri/permix/compare/v3.4.0...v3.4.1) (2025-07-23) + +### Bug Fixes + +- fix: add typescript export to avoid missing types + +### Documentation + +- docs: improve llms +- docs: add llms-full +- docs: fix example +- docs: rename page + +### Miscellaneous + +- chore: update packages +- refactor: simplify setup +- chore: update lock +- chore: add more test for hydration + +## [3.4.0](https://github.com/letstri/permix/compare/v3.3.0...v3.4.0) (2025-07-09) + +### Features + +- feat: add `template` function to backend integrations + +### Documentation + +- docs: update intro +- docs: fix solid example + +### Miscellaneous + +- chore: update packages +- refactor: remove `checkAsync` method from the backend integrations +- chore: update lock +- chore: clean dependencies + +## [3.3.0](https://github.com/letstri/permix/compare/v3.2.1...v3.3.0) (2025-06-24) + +### Features + +- feat: add Solid.js integration + +### Documentation + +- docs: add Solid.js example +- docs: add Solid.js integration +- docs: add `dataRequired` and fix styles + +### Miscellaneous + +- chore: update packages +- build: use Babel insted of esbuild +- chore: update packages + +## [3.2.1](https://github.com/letstri/permix/compare/v3.2.0...v3.2.1) (2025-06-23) + +### Documentation + +- docs: improve docs to new api + +### Miscellaneous + +- refactor: update exported types + +## [3.2.0](https://github.com/letstri/permix/compare/v3.1.0...v3.2.0) (2025-06-22) + +### Features + +- feat: add dataRequired prop and update datType type + +### Miscellaneous + +- chore: remove useless code, add console error +- refactor: improved javascript error handling + +## [3.1.0](https://github.com/letstri/permix/compare/v3.0.0...v3.1.0) (2025-06-22) + +### Features + +- feat: add Fastify integration + +### Documentation + +- docs: improve some parts +- docs: add Fastify integration +- docs: update orpc +- docs: add initial section +- docs: update orpc section + +### Miscellaneous + +- chore: fix lock +- refactor: update examples +- refactor: rename setState method +- build: add Fastify integration +- chore: update lock +- chore: update permix version +- chore: add external package + +## [3.0.0](https://github.com/letstri/permix/compare/v2.1.5...v3.0.0) (2025-06-21) + +### Features + +- feat: add elysia integration and packages updates + +### Documentation + +- docs: update setup due to refactor +- docs: add elysia +- docs: fix build +- docs: fix build + +### Miscellaneous + +- refactor: update trpc, orpc, vue integrations +- chore: add funding +- chore: update packages +- chore: add funding +- chore: add more tests + +## [2.1.5](https://github.com/letstri/permix/compare/v2.1.4...v2.1.5) (2025-04-09) + +### Bug Fixes + +- fix: add exports to utils + +### Documentation + +- docs: add orpc + +## [2.1.4](https://github.com/letstri/permix/compare/v2.1.3...v2.1.4) (2025-04-08) + +### Bug Fixes + +- fix: add orpc export + +## [2.1.3](https://github.com/letstri/permix/compare/v2.1.2...v2.1.3) (2025-04-08) + +### Bug Fixes + +- fix: add orpc to export + +## [2.1.2](https://github.com/letstri/permix/compare/v2.1.1...v2.1.2) (2025-04-08) + +### Bug Fixes + +- fix: orpc context + +## [2.1.1](https://github.com/letstri/permix/compare/v2.1.0...v2.1.1) (2025-04-08) + +### Bug Fixes + +- fix: trpc context + +## [2.1.0](https://github.com/letstri/permix/compare/v2.0.0...v2.1.0) (2025-04-08) + +### Features + +- feat: add orpc integration + +### Bug Fixes + +- fix: instance docs + +### Documentation + +- docs: update node and express +- docs: minor changes +- docs: minor +- docs: improve landing +- docs: fix type +- docs: update instance +- docs: add initial + +### Miscellaneous + +- refactor: trpc plugin to use initTRPC +- chore: update packages + +## [2.0.0](https://github.com/letstri/permix/compare/v2.0.0-rc.13...v2.0.0) (2025-03-06) + +### Features + +- feat: add test for initial state +- feat: add initial state for permix + +### Bug Fixes + +- fix: test for initial state + +### Miscellaneous + +- chore: update packages +- refactor: remove server +- refactor: remove server + +## [2.0.0-rc.13](https://github.com/letstri/permix/compare/v2.0.0-rc.12...v2.0.0-rc.13) (2025-03-03) + +### Bug Fixes + +- fix: export types + +## [2.0.0-rc.12](https://github.com/letstri/permix/compare/v2.0.0-rc.11...v2.0.0-rc.12) (2025-03-03) + +### Bug Fixes + +- fix: types + +### Documentation + +- docs: add get rules + +### Miscellaneous + +- chore: update versions + +## [2.0.0-rc.11](https://github.com/letstri/permix/compare/v2.0.0-rc.10...v2.0.0-rc.11) (2025-03-03) + +### Miscellaneous + +- refactor: rename method + +## [2.0.0-rc.10](https://github.com/letstri/permix/compare/v2.0.0-rc.9...v2.0.0-rc.10) (2025-03-03) + +### Documentation + +- docs: fix templates + +### Miscellaneous + +- refactor: improve state + +## [2.0.0-rc.9](https://github.com/letstri/permix/compare/v2.0.0-rc.8...v2.0.0-rc.9) (2025-03-03) + +### Documentation + +- docs: minor +- docs: update description +- docs: add link + +### Miscellaneous + +- refactor: templates +- refactor: internals +- chore: update packages + +## [2.0.0-rc.8](https://github.com/letstri/permix/compare/v2.0.0-rc.7...v2.0.0-rc.8) (2025-02-28) + +### Documentation + +- docs: update + +### Miscellaneous + +- refactor: backend internals + +## [2.0.0-rc.7](https://github.com/letstri/permix/compare/v2.0.0-rc.6...v2.0.0-rc.7) (2025-02-28) + +### Miscellaneous + +- refactor: backend adapters +- chore: improve tests, remove coverage +- refactor: improve server handlers +- refactor: improve server handlers +- Revert "refactor: internal server" +- Revert "refactor: minor" +- Revert "refactor: minor" +- refactor: minor +- refactor: minor +- refactor: internal server + +## [2.0.0-rc.6](https://github.com/letstri/permix/compare/v2.0.0-rc.5...v2.0.0-rc.6) (2025-02-27) + +### Bug Fixes + +- fix: docs + +### Documentation + +- docs: update version + +### Miscellaneous + +- refactor: rename functions + +## [2.0.0-rc.5](https://github.com/letstri/permix/compare/v2.0.0-rc.4...v2.0.0-rc.5) (2025-02-27) + +### Documentation + +- docs: update version + +### Miscellaneous + +- refactor: remove res + +## [2.0.0-rc.4](https://github.com/letstri/permix/compare/v2.0.0-rc.3...v2.0.0-rc.4) (2025-02-27) + +### Features + +- feat: add node and server + +### Documentation + +- docs: minor +- docs: update package + +### Miscellaneous + +- refactor: backend integrations + +## [2.0.0-rc.3](https://github.com/letstri/permix/compare/v2.0.0-rc.2...v2.0.0-rc.3) (2025-02-27) + +### Documentation + +- docs: update version + +### Miscellaneous + +- refactor: templates +- refactor: docs + +## [2.0.0-rc.2](https://github.com/letstri/permix/compare/v2.0.0-rc.1...v2.0.0-rc.2) (2025-02-27) + +### Miscellaneous + +- refactor: packages +- refactor: templates + +## [2.0.0-rc.1](https://github.com/letstri/permix/compare/v2.0.0-beta.1...v2.0.0-rc.1) (2025-02-26) + +### Bug Fixes + +- fix: workflow +- fix: workflow +- fix: workflow + +### Documentation + +- docs: update package + +### Miscellaneous + +- refactor: templates +- chore: update package +- chore: update packages + +## [2.0.0-beta.1](https://github.com/letstri/permix/compare/v1.0.4...v2.0.0-beta.1) (2025-02-26) + +### Bug Fixes + +- fix: trpc middleware types +- fix: express example + +### Documentation + +- docs: update express, hono, trpc based on new features + +### Miscellaneous + +- refactor: hono middleware +- chore: fix lock +- refactor: internal instances +- refactor: remove next and nuxt +- refactor: remove nuxt +- refactor: remove next and nuxt +- refactor: trpc middleware +- refactor: rename express methods +- refactor: express minor +- refactor: rename express instance +- refactor: finish express middlewares +- refactor: minor updates +- refactor: minor updates +- refactor: update template, refactor express middleware +- refactor: update types +- refactor: update types +- chore: improve examples + +## [1.0.4](https://github.com/letstri/permix/compare/v1.0.3...v1.0.4) (2025-02-04) + +### Bug Fixes + +- fix: vue prop type + +### Documentation + +- docs: fix nuxt +- docs: fix nuxt + +## [1.0.3](https://github.com/letstri/permix/compare/v1.0.2...v1.0.3) (2025-02-03) + +### Documentation + +- docs: add known issues + +### Miscellaneous + +- refactor: internal interfaces +- chore: update coverage +- chore: add build + +## [1.0.2](https://github.com/letstri/permix/compare/v1.0.1...v1.0.2) (2025-02-02) + +### Bug Fixes + +- fix: remove useless prop + +## [1.0.1](https://github.com/letstri/permix/compare/v1.0.0...v1.0.1) (2025-02-02) + +### Features + +- feat: add new interface to use as components definition + +### Bug Fixes + +- fix: lock + +### Documentation + +- docs: fix vue +- docs: update instance +- docs: update instance +- docs: fix names +- docs: update introduction +- docs: update introduction +- docs: update introduction +- docs: update introduction +- docs: improve introduction +- docs: change permix version +- docs: change permix to workspace +- docs: fix link +- docs: fix typo + +### Miscellaneous + +- chore: ignore test for nuxt +- refactor: rename commitlint config +- refactor: update spaces + +## [1.0.0](https://github.com/letstri/permix/compare/v1.0.0-rc.2...v1.0.0) (2025-01-22) + +### Features + +- feat: add to react and vue check component `otherwise` + +### Bug Fixes + +- fix: react context updates +- fix: trpc middleware types +- fix: sandbox +- fix: sandbox +- fix: react component generic + +### Documentation + +- docs: update examples +- docs: minor improvements +- docs: remove useless +- docs: improvements +- docs: minor improvements +- docs: add compare, feature flags, some imrovements +- docs: update integrations + +### Miscellaneous + +- chore: update lock +- refactor: remove infers +- refactor: use method to check isready +- chore: improve vue tests +- refactor: improve build +- chore: add more examples +- refactor: remove useless type +- refactor: tests +- chore: update examples +- chore: add more examples +- chore: minor improvements +- refactor: internal serialization +- chore: update example +- refactor: slots +- chore: update examples +- refactor: vue component + +## [1.0.0-rc.2](https://github.com/letstri/permix/compare/v1.0.0-rc.1...v1.0.0-rc.2) (2025-01-22) + +### Features + +- feat: add vue check component +- feat: add react component + +### Miscellaneous + +- chore: update coverage +- chore: add more examples +- chore: update packages + +## [1.0.0-rc.1](https://github.com/letstri/permix/compare/v0.8.0...v1.0.0-rc.1) (2025-01-22) + +### Features + +- feat: add isReadyAsync +- feat: add nuxt + +### Documentation + +- docs: improvements +- docs: add nuxt integration +- docs: improvements +- docs: add files to react + +## [0.8.0](https://github.com/letstri/permix/compare/v0.7.3...v0.8.0) (2025-01-21) + +### Features + +- feat: improve hydration + +### Bug Fixes + +- fix: lock + +### Documentation + +- docs: add nextjs +- docs: add hydration +- docs: update enums +- docs: improve docs +- docs: add analytics +- docs: add analytics +- docs: add examples +- docs: update version +- docs: update version +- docs: add integrations + +### Miscellaneous + +- refactor: internals +- refactor: vue composable +- refactor: coverage +- refactor: remove async from setup, add custom hooks, update tests +- refactor: internals +- refactor: internals +- chore: update next example +- refactor: update internals +- chore: update lock +- refactor: react internal +- chore: updates +- chore: add next example +- refactor: internal checks +- refactor: add links + +## [0.7.3](https://github.com/letstri/permix/compare/v0.7.2...v0.7.3) (2025-01-18) + +### Documentation + +- docs: finish main section + +### Miscellaneous + +- refactor: add links, update hooks + +## [0.7.2](https://github.com/letstri/permix/compare/v0.7.2-beta.2...v0.7.2) (2025-01-18) + +### Bug Fixes + +- fix: publish command +- fix: publish command +- fix: publish command + +## [0.7.2-beta.2](https://github.com/letstri/permix/compare/v0.7.2-beta.1...v0.7.2-beta.2) (2025-01-18) + +- Published to npm. + +## [0.7.2-beta.1](https://github.com/letstri/permix/compare/v0.7.1...v0.7.2-beta.1) (2025-01-18) + +### Bug Fixes + +- fix: publish command + +### Miscellaneous + +- refactor: remove readme + +## [0.7.1](https://github.com/letstri/permix/compare/v0.7.0...v0.7.1) (2025-01-18) + +### Features + +- feat: add auto-publish + +### Bug Fixes + +- fix: publish command +- fix: publish command +- fix: publish command +- fix: publish command + +### Documentation + +- docs: remove useless file +- docs: add quick start + +### Miscellaneous + +- refactor: add scripts and coverage +- chore: change docs link +- chore: change docs link + +## [0.7.0](https://github.com/letstri/permix/compare/v0.6.0...v0.7.0) (2025-01-17) + +### Features + +- feat: add async check + +### Bug Fixes + +- fix: temp remove setup from options + +### Miscellaneous + +- refactor: rename type + +## [0.6.0](https://github.com/letstri/permix/compare/v0.5.0...v0.6.0) (2025-01-17) + +### Features + +- feat: improve error handling + +### Miscellaneous + +- chore: add link to docs + +## [0.5.0](https://github.com/letstri/permix/compare/v0.4.2...v0.5.0) (2025-01-17) + +### Features + +- feat: add to react and vue `isReady` + +### Miscellaneous + +- chore: add examples + +## [0.4.2](https://github.com/letstri/permix/compare/v0.4.1...v0.4.2) (2025-01-17) + +### Bug Fixes + +- fix: trpc types context + +### Miscellaneous + +- chore: update package + +## [0.4.1](https://github.com/letstri/permix/compare/v0.4.0...v0.4.1) (2025-01-17) + +### Documentation + +- docs: add introduction + +### Miscellaneous + +- refactor: remove initialPermissions + +## [0.4.0](https://github.com/letstri/permix/compare/v0.3.5...v0.4.0) (2025-01-16) + +### Features + +- feat: add param to template + +### Bug Fixes + +- fix: script + +### Miscellaneous + +- chore: update lock +- chore: update packages + +## [0.3.5](https://github.com/letstri/permix/compare/v0.3.4...v0.3.5) (2025-01-16) + +### Bug Fixes + +- fix: backend types + +## [0.3.4](https://github.com/letstri/permix/compare/v0.3.3...v0.3.4) (2025-01-16) + +### Features + +- feat: add permissions definition + +## [0.3.3](https://github.com/letstri/permix/compare/v0.3.2...v0.3.3) (2025-01-16) + +### Bug Fixes + +- fix: react and vue `check` methods + +### Miscellaneous + +- refactor: improve internal methods + +## [0.3.2](https://github.com/letstri/permix/compare/v0.3.1...v0.3.2) (2025-01-16) + +### Bug Fixes + +- fix: reexport in react and vue + +### Miscellaneous + +- chore: update descriptions +- refactor: remove useless directive + +## [0.3.1](https://github.com/letstri/permix/compare/v0.3.0...v0.3.1) (2025-01-15) + +### Bug Fixes + +- fix: datatype + +## [0.3.0](https://github.com/letstri/permix/compare/v0.2.1...v0.3.0) (2025-01-15) + +### Miscellaneous + +- refactor: make all permissions required, fix vue and react adapters +- refactor: temp remove examples + +## [0.2.1](https://github.com/letstri/permix/compare/v0.2.0...v0.2.1) (2025-01-15) + +### Bug Fixes + +- fix: type in frontend +- fix: readme + +### Documentation + +- docs: update frontend +- docs: update backend +- docs: update backend +- docs: update backend + +### Miscellaneous + +- chore: remove useless command + +## [0.2.0](https://github.com/letstri/permix/compare/v0.1.2...v0.2.0) (2025-01-15) + +### Features + +- feat: add `all` permission + +### Documentation + +- docs: remove static +- docs: temp comment links +- docs: update landing + +### Miscellaneous + +- refactor: improve prepublish command + +## [0.1.2](https://github.com/letstri/permix/compare/v0.1.1...v0.1.2) (2025-01-15) + +### Bug Fixes + +- fix: text examples + +### Miscellaneous + +- chore: add some text +- refactor: add private + +## [0.1.1](https://github.com/letstri/permix/compare/v0.1.0...v0.1.1) (2025-01-15) + +### Miscellaneous + +- refactor: update descriptions +- refactor: remove turbo + +## [0.1.0](https://github.com/letstri/permix/compare/v0.0.1...v0.1.0) (2025-01-15) + +### Features + +- feat: add nuxt adapter +- feat: add next and nuxt examples +- feat: add hono middleware +- feat: add express middleware + +### Bug Fixes + +- fix: build +- fix: react and vue reactivity + +### Documentation + +- docs: init + +### Miscellaneous + +- refactor: update tests +- chore: remove useless variable +- chore: improve tests +- refactor: add jsdoc + +## [0.0.1](https://github.com/letstri/permix/compare/v0.0.1-alpha.1...v0.0.1) (2025-01-14) + +### Miscellaneous + +- refactor: add workspaces, commitlint, turbo, some permix stuff +- update readme +- Create README.md +- init first version +- Initial commit + +## [0.0.1-alpha.1](https://github.com/letstri/permix/releases/tag/v0.0.1-alpha.1) (2025-01-13) + +- Published to npm. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..269566fb --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,79 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at valerii.strilets@gmail.com. All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of actions. + +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at https://www.contributor-covenant.org/version/2/1/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/inclusion). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..ca0a9cb8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,66 @@ +# Contributing + +Thanks for helping improve Permix. + +## Prerequisites + +- Node `>=22` and pnpm `>=11` (`packageManager` in root `package.json`) +- Run `pnpm install` from the repository root + +## Development workflow + +```bash +pnpm format +pnpm lint +pnpm test +pnpm check-types +pnpm verify +``` + +`pnpm verify` is the closest local equivalent of the main CI quality gate (format, lint, tests, types, and the library build). CI also runs `pnpm test` and `pnpm test:next` as separate jobs. Use `pnpm verify:full` to run `verify` plus the Next Playwright suite locally. + +Use focused branches. Commit messages must follow [Conventional Commits](https://www.conventionalcommits.org/): + +``` +feat: add createSetupHandler for TanStack Start +fix(react): re-run client setup after re-hydration +docs: document Permix checks in beforeLoad +chore: bump oxlint +``` + +Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`. Breaking changes use `feat!:` / `fix!:` or a `BREAKING CHANGE:` footer. Husky runs commitlint on `commit-msg`. + +## Change expectations + +- Add or adjust tests when behavior changes in a meaningful way. +- Keep `permix/skills/` aligned with `docs/content/docs/` and `examples/` when public API, docs examples, or integration patterns change. +- Keep `permix/benchmarks/entries/` and bundle-size budgets aligned with every public export. After building, run `pnpm --filter permix size:compare`. +- Treat bundle-size baseline and budget updates as reviewed product changes: inspect the generated bundle and explain intentional growth instead of raising limits only to pass CI. +- Do not bump `permix` version or edit released changelog sections by hand. Release Please opens a release PR from conventional commits. + +## Documentation + +API documentation belongs in `docs/content/docs`. The [changelog page](https://permix.letstri.dev/docs/changelog) reads repo-root `CHANGELOG.md`; do not duplicate release notes in MDX. + +```bash +cd docs && pnpm dev # http://localhost:3000 +``` + +## Pull requests + +- Fill out the PR template. +- Link related issues. +- Include screenshots or recordings for docs-site UI changes. + +## Releases + +Releases are driven by Release Please on `main`. Merging the release PR creates a `vMAJOR.MINOR.PATCH` GitHub Release; the provenance-enabled publish workflow then publishes `permix` to npm. After a release, CI may open a skills review PR. + +Historical npm versions that predate this process can be tagged locally with: + +```bash +node scripts/tag-historical-releases.mjs # dry run +node scripts/tag-historical-releases.mjs --apply # create annotated tags +``` + +Do not bulk-create GitHub Releases for those tags. diff --git a/README.md b/README.md index 7b38be33..4cbbf028 100644 --- a/README.md +++ b/README.md @@ -13,21 +13,33 @@ You can find the documentation [here](https://permix.letstri.dev). To quick start you only need to write the following code: ```ts -import { createPermix } from 'permix' +import { permission } from 'permix' + +export const canReadPost = permission({ + key: 'post.read', + title: 'Read posts', +}) +``` -const permix = createPermix<{ - post: ['read'] -}>() +```bash +pnpm permix extract +``` -permix.setup({ - post: { - read: true, - }, +```ts +import { createPermix } from 'permix' +import { type Definition, permissions } from './.permix/permissions' + +const permix = createPermix().setup({ + post: { read: true }, }) -permix.check('post.read') // true +permix.check(permissions.post.read) // true ``` +Mark paths with `permission()`, generate a typed catalog with `permix extract`, then pass `Definition` to `createPermix`. Manual generics (`createPermix<{ post: ['read'] }>()`) remain the escape hatch for dynamic keys. + +`setup()` returns a frozen instance and does not mutate the factory. On the server, stash that returned instance per request (`setupMiddleware`, or `createPermix().setup(rules)` inside the request). Do not share one instance across concurrent requests. + Permix has other powerful features, so here's check out the [docs](https://permix.letstri.dev/docs) or the [examples](https://github.com/letstri/permix/tree/main/examples) directory. ## Agent skills (TanStack Intent) @@ -40,6 +52,14 @@ pnpm dlx @tanstack/intent@latest install Skills are indexed on the [Agent Skills Registry](https://tanstack.com/intent/registry) and update when you update the package. +## Changelog + +Release notes are in [CHANGELOG.md](CHANGELOG.md) and on the docs site at [permix.letstri.dev/docs/changelog](https://permix.letstri.dev/docs/changelog). + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md). Please follow the [Code of Conduct](CODE_OF_CONDUCT.md). To report a vulnerability, see [SECURITY.md](SECURITY.md). + ## License MIT License - see the [LICENSE](https://github.com/letstri/permix/blob/main/LICENSE) file for details diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..c9416973 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,13 @@ +# Security Policy + +## Supported versions + +Security fixes are applied to the latest `permix` release on npm. Please upgrade before reporting issues that are already fixed. + +## Reporting a vulnerability + +Do not open a public GitHub issue for security reports. + +Report vulnerabilities through [GitHub private security advisories](https://github.com/letstri/permix/security/advisories/new). We will acknowledge the report, investigate, and coordinate a fix and disclosure. + +If GitHub advisories are unavailable, email [valerii.strilets@gmail.com](mailto:valerii.strilets@gmail.com). diff --git a/_artifacts/domain_map.yaml b/_artifacts/domain_map.yaml index 318aa7c9..a1700ff3 100644 --- a/_artifacts/domain_map.yaml +++ b/_artifacts/domain_map.yaml @@ -34,12 +34,13 @@ domains: slug: server description: > Per-request setupMiddleware and checkMiddleware for Express, Hono, Fastify, - tRPC, oRPC, Node, and Elysia. + NestJS, tRPC, oRPC, Node, Elysia, and Astro. - name: 'SSR and hydration' slug: ssr description: > - dehydrate/hydrate snapshots, PermixHydrate, Next.js and TanStack Start wiring. + dehydrate/hydrate snapshots, PermixHydrate, Next.js, TanStack Start, Nuxt, + and React Router wiring. skills: - name: 'Getting started' @@ -68,6 +69,8 @@ skills: - 'letstri/permix:docs/content/docs/guide/instance.mdx' - 'letstri/permix:docs/content/docs/guide/events.mdx' - 'letstri/permix:docs/content/docs/migration-v3-to-v4.mdx' + - 'letstri/permix:docs/content/docs/migration-v4-to-v5.mdx' + - 'letstri/permix:docs/content/docs/integrations/standard-schema.mdx' - 'letstri/permix:permix/src/core/index.ts' - name: 'Permix (check, frontend, server)' @@ -76,9 +79,10 @@ skills: description: > Everything past initial setup: dot-path check, callback combinators, ~all/~any, entity-aware ReBAC rules, isReady/isReadyAsync; - PermixProvider/usePermix/createComponents and SSR dehydrate/hydrate for - React, Vue, Solid, Svelte, Next.js, TanStack Start; setupMiddleware and - checkMiddleware for Express, Hono, Fastify, tRPC, oRPC, Node, Elysia. + PermixProvider/usePermix/Check from createPermix() in permix/react (or vue/solid/svelte) and SSR dehydrate/hydrate/install for + React, Vue, Solid, Svelte, Next.js, TanStack Start, Nuxt, React Router; + setupMiddleware and checkMiddleware for Express, Hono, Fastify, NestJS, + tRPC, oRPC, Node, Elysia, Astro. Single skill with a thin SKILL.md router and three reference files loaded on demand. type: core @@ -91,13 +95,17 @@ skills: - svelte - next - tanstack-start + - nuxt + - react-router - express - hono - fastify + - nest - trpc - orpc - node - elysia + - astro covers: - check - isReady @@ -105,45 +113,53 @@ skills: - "'~all' / '~any'" - PermixProvider - usePermix - - createComponents - Check component - dehydrate - hydrate + - install - PermixHydrate - setupMiddleware - checkMiddleware - getOrThrow + - permission extraction tasks: - 'Gate UI or API logic with permix.check' - 'Implement resource-level authorization with entity data' - 'Wrap the app tree and hide actions based on permissions' - 'Integrate permix/react, permix/vue, permix/solid, or permix/svelte' - 'Pass permission booleans from server render to client' - - 'Wire permix/next or permix/tanstack-start' + - 'Wire permix/next, permix/tanstack-start, permix/nuxt, or permix/react-router' - 'Protect HTTP or RPC routes on the server' - 'Derive rules from authenticated request context' + - 'Generate typed permission catalogs from source markers' references: - 'references/check.md' - 'references/frontend.md' - 'references/server.md' + - 'references/extraction.md' sources: - 'letstri/permix:docs/content/docs/guide/check.mdx' - 'letstri/permix:docs/content/docs/guide/rebac.mdx' - 'letstri/permix:docs/content/docs/guide/ready.mdx' - 'letstri/permix:docs/content/docs/guide/hydration.mdx' + - 'letstri/permix:docs/content/docs/guide/extraction.mdx' - 'letstri/permix:docs/content/docs/integrations/react.mdx' - 'letstri/permix:docs/content/docs/integrations/vue.mdx' - 'letstri/permix:docs/content/docs/integrations/solid.mdx' - 'letstri/permix:docs/content/docs/integrations/svelte.mdx' - 'letstri/permix:docs/content/docs/integrations/next.mdx' - 'letstri/permix:docs/content/docs/integrations/tanstack-start.mdx' + - 'letstri/permix:docs/content/docs/integrations/nuxt.mdx' + - 'letstri/permix:docs/content/docs/integrations/react-router.mdx' - 'letstri/permix:docs/content/docs/integrations/express.mdx' - 'letstri/permix:docs/content/docs/integrations/hono.mdx' - 'letstri/permix:docs/content/docs/integrations/fastify.mdx' + - 'letstri/permix:docs/content/docs/integrations/nest.mdx' - 'letstri/permix:docs/content/docs/integrations/trpc.mdx' - 'letstri/permix:docs/content/docs/integrations/orpc.mdx' - 'letstri/permix:docs/content/docs/integrations/node.mdx' - 'letstri/permix:docs/content/docs/integrations/server.mdx' + - 'letstri/permix:docs/content/docs/integrations/astro.mdx' - 'letstri/permix:docs/content/docs/integrations/elysia.mdx' - 'letstri/permix:permix/src/core/check.ts' @@ -153,6 +169,9 @@ coverage: - examples/* - next - tanstack-start + - nuxt + - astro + - react-router failure_modes: - mistake: 'Using v3 { action, dataType } schema shape' @@ -178,8 +197,7 @@ failure_modes: - mistake: 'Relying on dehydrate alone for dynamic rules' impact: 'Function-based ReBAC checks wrong after SSR hydrate' correct_pattern: | - permix.hydrate(state) - permix.setup(clientRulesForUser) + permix.install({ dehydrated: state, rules: clientRulesForUser }) source: 'letstri/permix:docs/content/docs/guide/hydration.mdx' priority: CRITICAL status: active diff --git a/_artifacts/skill_spec.md b/_artifacts/skill_spec.md index 143b1571..4473e4dc 100644 --- a/_artifacts/skill_spec.md +++ b/_artifacts/skill_spec.md @@ -47,7 +47,7 @@ When `docs/content/docs/` or public API in `permix/src/` changes: ## Out of scope (docs-only for now) -- `permix/effect`, `permix/drizzle` — documented at https://permix.letstri.dev/docs/integrations/effect and `/drizzle`; no dedicated skill yet. +- `permix/effect`, `permix/drizzle`, `permix/standard-schema` — documented at https://permix.letstri.dev/docs/integrations/effect, `/drizzle`, and `/standard-schema`; no dedicated skill yet. ## Registry diff --git a/_artifacts/skill_tree.yaml b/_artifacts/skill_tree.yaml index 70b42866..f377637e 100644 --- a/_artifacts/skill_tree.yaml +++ b/_artifacts/skill_tree.yaml @@ -27,6 +27,8 @@ skills: - 'letstri/permix:docs/content/docs/guide/instance.mdx' - 'letstri/permix:docs/content/docs/guide/events.mdx' - 'letstri/permix:docs/content/docs/migration-v3-to-v4.mdx' + - 'letstri/permix:docs/content/docs/migration-v4-to-v5.mdx' + - 'letstri/permix:docs/content/docs/integrations/standard-schema.mdx' - 'letstri/permix:permix/src/core/index.ts' - name: 'Permix (check, frontend, server)' @@ -37,10 +39,11 @@ skills: package: permix description: > check() dot paths, callbacks, ~all/~any, entity-aware ReBAC rules, - isReady/isReadyAsync; PermixProvider/usePermix/createComponents and SSR - dehydrate/hydrate for React, Vue, Solid, Svelte, Next.js, TanStack - Start; setupMiddleware/checkMiddleware for Express, Hono, Fastify, - tRPC, oRPC, Node, Elysia. Thin router with references loaded on demand. + isReady/isReadyAsync; factory createPermix from permix/react (vue/solid/svelte) + with bound Provider/usePermix/Check and SSR dehydrate/hydrate/install for React, Vue, Solid, Svelte, Next.js, TanStack + Start, Nuxt, React Router; setupMiddleware/checkMiddleware for Express, + Hono, Fastify, NestJS, tRPC, oRPC, Node, Elysia, Astro. Thin router with + references loaded on demand. requires: - permix-getting-started subsystems: @@ -50,35 +53,46 @@ skills: - svelte - next - tanstack-start + - nuxt + - react-router - express - hono - fastify + - nest - trpc - orpc - node - elysia + - astro + - extractor references: - 'references/check.md' - 'references/frontend.md' - 'references/server.md' + - 'references/extraction.md' sources: - 'letstri/permix:docs/content/docs/guide/check.mdx' - 'letstri/permix:docs/content/docs/guide/rebac.mdx' - 'letstri/permix:docs/content/docs/guide/ready.mdx' - 'letstri/permix:docs/content/docs/guide/hydration.mdx' + - 'letstri/permix:docs/content/docs/guide/extraction.mdx' - 'letstri/permix:docs/content/docs/integrations/react.mdx' - 'letstri/permix:docs/content/docs/integrations/vue.mdx' - 'letstri/permix:docs/content/docs/integrations/solid.mdx' - 'letstri/permix:docs/content/docs/integrations/svelte.mdx' - 'letstri/permix:docs/content/docs/integrations/next.mdx' - 'letstri/permix:docs/content/docs/integrations/tanstack-start.mdx' + - 'letstri/permix:docs/content/docs/integrations/nuxt.mdx' + - 'letstri/permix:docs/content/docs/integrations/react-router.mdx' - 'letstri/permix:docs/content/docs/integrations/express.mdx' - 'letstri/permix:docs/content/docs/integrations/hono.mdx' - 'letstri/permix:docs/content/docs/integrations/fastify.mdx' + - 'letstri/permix:docs/content/docs/integrations/nest.mdx' - 'letstri/permix:docs/content/docs/integrations/trpc.mdx' - 'letstri/permix:docs/content/docs/integrations/orpc.mdx' - 'letstri/permix:docs/content/docs/integrations/node.mdx' - 'letstri/permix:docs/content/docs/integrations/server.mdx' + - 'letstri/permix:docs/content/docs/integrations/astro.mdx' - 'letstri/permix:docs/content/docs/integrations/elysia.mdx' - 'letstri/permix:permix/src/core/check.ts' @@ -88,3 +102,6 @@ coverage: - examples/* - next - tanstack-start + - nuxt + - astro + - react-router diff --git a/commitlint.config.mts b/commitlint.config.mts new file mode 100644 index 00000000..4e03c10d --- /dev/null +++ b/commitlint.config.mts @@ -0,0 +1,5 @@ +const config = { + extends: ['@commitlint/config-conventional'], +} + +export default config diff --git a/docs/content/docs/changelog.mdx b/docs/content/docs/changelog.mdx new file mode 100644 index 00000000..571634ff --- /dev/null +++ b/docs/content/docs/changelog.mdx @@ -0,0 +1,5 @@ +--- +title: Changelog +description: Release notes for the permix npm package +icon: RiHistoryLine +--- diff --git a/docs/content/docs/comparison.mdx b/docs/content/docs/comparison.mdx index d0d9ea67..62076f60 100644 --- a/docs/content/docs/comparison.mdx +++ b/docs/content/docs/comparison.mdx @@ -19,40 +19,24 @@ Permix is a library that provides a way to manage permissions in your applicatio | Events | ✅ | ❌ | | Simple DX | ✅ Create instance, use built-in integrations | ❌ In CASL you need to manage a lot of stuff manually (type-safe, hydration, etc.) | | Modernity | ✅ Uses modern updates and features of each lib and framework | ❌ CASL was created a long time ago and hasn't updated the core | -| Size | **2.64 kB** gzip (core) [react 0.86 kB, vue 0.87 kB, solid 0.82 kB, next 1.00 kB, tanstack-start 2.05 kB, svelte ~2.17 kB, …] | **6.17 kB** min+gzip (core) [@casl/react 0.62 kB] | +| Size | **1.27 kB** min+gzip for a realistic core fixture; **1.71 kB** with the classic React provider and hook | **6.17 kB** min+gzip (core) [@casl/react 0.62 kB] | -Sizes are hard numbers from published builds — see [Bundle size](#bundle-size). Bracketed values are integration adapters imported on top of core. +Permix figures come from reproducible consumer-style bundles — see [Bundle size](#bundle-size). ## Bundle size -All figures are **gzip**. Measured on **2026-06-02** from production builds. +Permix figures below were measured on **2026-08-28** from the committed bundle-size baseline. Both columns are bytes emitted by a production consumer bundle, shown as decimal-free source bytes converted to kB for readability. ### Permix -Built with `pnpm run build` in the `permix` package (`tsdown` for all entries except Svelte). +The benchmark first builds the package, then uses esbuild to bundle tiny, realistic fixtures from `permix/benchmarks/entries`. Each fixture keeps the used Permix APIs alive, emits minified ESM with `NODE_ENV=production`, and is compressed with gzip level 9. Framework peer dependencies such as React are external, matching how applications normally deduplicate peers. -| Entry | gzip | Notes | -| --- | --: | --- | -| **`permix` (core)** | **2.64 kB** | `dist/core/index.mjs` (7.96 kB raw) | -| `permix/react` | 0.86 kB | adapter | -| `permix/vue` | 0.87 kB | adapter | -| `permix/solid` | 0.82 kB | adapter | -| `permix/svelte` | ~2.17 kB | adapter (`dist/svelte/`, `svelte-package` build) | -| `permix/next` | 1.00 kB | adapter | -| `permix/tanstack-start` | 2.05 kB | adapter | -| `permix/node` | 0.95 kB | adapter | -| `permix/server` | 1.10 kB | adapter | -| `permix/express` | 0.91 kB | adapter | -| `permix/hono` | 0.88 kB | adapter | -| `permix/fastify` | 1.04 kB | adapter | -| `permix/elysia` | 0.88 kB | adapter | -| `permix/trpc` | 0.96 kB | adapter | -| `permix/orpc` | 0.92 kB | adapter | -| `permix/effect` | 1.28 kB | adapter | -| `permix/drizzle` | 0.89 kB | adapter | -| `permix/drizzle/legacy` | 0.83 kB | adapter | +| Fixture | Minified | Minified + gzip | What it exercises | +| --- | --: | --: | --- | +| **Core** | **2.89 kB** | **1.27 kB** | Creates a typed instance, supplies rules, and checks a permission | +| **React classic** | **3.97 kB** | **1.71 kB** | Core instance plus `PermixProvider` and `usePermix` | -Integration entries import `../core/index.mjs`, so a typical app ships **core + adapter** (for example React ≈ **2.64 + 0.86 ≈ 3.50 kB** gzip of published chunks before your bundler minifies further). +These are complete fixture bundles, not sizes of unminified files in `dist/`, and the React number already includes the Permix core used by that fixture. The CI harness also measures every other public export and rejects unexpected extractor dependencies in browser bundles. ### CASL (`@casl/ability@7.0.0`, `@casl/react@7.0.0`) diff --git a/docs/content/docs/guide/check.mdx b/docs/content/docs/guide/check.mdx index 6932636e..90192b47 100644 --- a/docs/content/docs/guide/check.mdx +++ b/docs/content/docs/guide/check.mdx @@ -113,6 +113,30 @@ permix.check('invalid-entity.create') Invalid paths cause a TypeScript error at compile time. At runtime, checking an undefined path throws `PermixRuleNotDefinedError`. +## Explain + +`check()` always returns a boolean. Use `explain()` with the same arguments when you need to show **why** a path was denied: + +```ts +permix.setup({ + post: { + update: (post) => + post.authorId === currentUserId + ? true + : { allow: false, reason: 'Only the author can edit this post' }, + }, +}) + +permix.check('post.update', post) // false + +const { allowed, path, reasons } = permix.explain('post.update', post) +// { allowed: false, path: 'post.update', reasons: ['Only the author can edit this post'] } +``` + +Boolean rules and `{ allow: true }` produce an empty `reasons` array. `'~all'` / `'~any'` collect reasons from every denied leaf. `dehydrate()` still collapses function rules to `true`/`false` — reasons are not serialized. + +`onForbidden` handlers and `PermixForbiddenError` carry the same `{ path, reasons }` payload. UI adapters expose `explain` next to `check` on `usePermix()` so tooltips can use the rule reason instead of a guessed string. + ## Errors Permix throws typed errors from `permix` when checks cannot run safely: diff --git a/docs/content/docs/guide/events.mdx b/docs/content/docs/guide/events.mdx index c9c59ec1..09ebe3b7 100644 --- a/docs/content/docs/guide/events.mdx +++ b/docs/content/docs/guide/events.mdx @@ -40,21 +40,21 @@ Available events: - `setup` - Triggered when permissions are updated through the `setup` method or `hydrate()`. - `ready` - Triggered **once** when the instance first becomes ready (first `setup()`, or initial rules passed to `createPermix`). Later `setup()` calls do not fire `ready` again. `hydrate()` alone does not trigger `ready`. -- `check` - Triggered every time `check()` is called. The handler receives a context object with `path` and `data`. +- `check` - Triggered after every `check()` evaluation. The handler receives `{ path, data?, allowed, reasons, error? }`. `explain()` does not fire this hook. Use the `setup` event when UI or caches should refresh after every permission change. Use `ready` only for one-time bootstrap (for example, hiding a global loading shell). Use `check` for logging, analytics, or debugging permission evaluations. ## Check Event -The `check` event fires before every permission evaluation and provides the path and data being checked: +The `check` event fires **after** every permission evaluation and includes the result: ```ts const permix = createPermix<{ post: ['create', { name: 'edit'; type: { authorId: string }; required: true }] }>() -permix.hook('check', ({ path, data }) => { - console.log(`Checking permission: ${path}`, data) +permix.hook('check', ({ path, data, allowed, reasons }) => { + console.log(`Checked ${path}: ${allowed}`, data, reasons) }) permix.setup({ @@ -65,15 +65,15 @@ permix.setup({ }) permix.check('post.create') -// logs: Checking permission: post.create undefined +// logs: Checked post.create: true undefined [] permix.check('post.edit', { authorId: '1' }) -// logs: Checking permission: post.edit { authorId: '1' } +// logs: Checked post.edit: true { authorId: '1' } [] ``` When using the callback form of `check()`, `path` will be `null`: ```ts permix.check((c) => c('post.create') && c('post.edit', { authorId: '1' })) -// logs: Checking permission: null undefined +// logs: Checked null: false undefined [] ``` diff --git a/docs/content/docs/guide/extraction.mdx b/docs/content/docs/guide/extraction.mdx new file mode 100644 index 00000000..51f98944 --- /dev/null +++ b/docs/content/docs/guide/extraction.mdx @@ -0,0 +1,141 @@ +--- +title: Permission extraction +description: Generate typed permission constants, definitions, and metadata from application code +--- + +Permission extraction is opt-in. It turns explicit permission markers in your application into: + +- `.permix/permissions.ts`, with a key union, nested constants, metadata, and a `Definition` for every Permix adapter +- `.permix/permissions.json`, with versioned metadata and every source reference + +The generated catalog describes your permission vocabulary. It does not grant access, synchronize a database, or replace `setup()` rules. + +## Mark permissions + +Import `permission` directly from `permix`. The function returns the key unchanged, so it can be used wherever a string permission path is accepted. + +```ts +import { permission } from 'permix' + +export const canComment = permission({ + key: 'tasks.comment', + title: 'Comment on tasks', + description: 'Add a comment to an existing task.', + tags: ['tasks', 'collaboration'], + annotations: { + area: 'work-management', + risk: 'standard', + surfaces: ['web', 'api', 'ai-tool'], + }, +}) +``` + +The extractor follows direct named imports, aliases, and namespace imports in JS, JSX, TS, and TSX files. Keys and metadata must be static. Dynamic values, spreads, malformed metadata, and conflicting metadata for the same key fail the scan with a file and line diagnostic. + +## Generate the catalog + +```bash +pnpm permix extract +pnpm permix extract --watch +pnpm permix extract --check +``` + +The CLI and `withPermix` depend on `chokidar`, `oxc-parser`, and `tinyglobby`. Those packages are optional peers of `permix`, so a UI-only install does not download the native parser. Install them (or reinstall `permix` with optional dependencies enabled) before running extract. + +Use repeatable `--include` and `--exclude` options for monorepos: + +```bash +pnpm permix extract \ + --cwd ../.. \ + --include "apps/dashboard/src/**/*.{ts,tsx}" \ + --include "packages/features/src/**/*.{ts,tsx}" \ + --module-output apps/dashboard/src/permissions.generated.ts \ + --catalog-output apps/dashboard/permissions.json +``` + +`--check` does not write files. It exits non-zero when either artifact is missing or stale, which makes it suitable for CI. + +## Use generated types and constants + +```ts +import { createPermix } from 'permix' +import { type Definition, permissions } from './.permix/permissions' + +export const permix = createPermix() + +permix.setup({ + tasks: { + comment: true, + }, +}) + +permix.check(permissions.tasks.comment) +``` + +Manual definitions remain supported. You can migrate one application at a time. + +## Add typed payload data + +Extraction never serializes validators or erased TypeScript types. Add payload types with the generated overlay helper and the existing `action()` API: + +```ts +import { action, createPermix } from 'permix' +import { z } from 'zod' +import { type Definition, definePermissionOverlay } from './.permix/permissions' + +const taskSchema = z.object({ taskId: z.string() }) + +const overlay = definePermissionOverlay({ + tasks: [action('comment', taskSchema, { required: true })], +}) + +type AppDefinition = Definition + +export const permix = createPermix() +``` + +Overlay paths must already exist in extracted source. The schema remains in application code and is not copied into either generated artifact. Extraction also does not enable runtime validation; use the explicit `permix/standard-schema` factory options when runtime validation is required. That factory validates by the first path segment, not by an individual deep permission path. + +## Enrich metadata centrally + +Inline metadata supplies defaults. A typed central config can replace or fill presentation fields. Run extraction once to create the generated helper before adding this config: + +```ts +import { definePermissionConfig } from './.permix/permissions' + +export const permissionMetadata = definePermissionConfig({ + 'tasks.comment': { + title: 'Comment on a task', + description: 'Available from the task page, API, and AI tools.', + }, +}) +``` + +Pass that object as `metadata` to `generatePermissions`, `watchPermissions`, or `createPermixPlugin`. Unknown config keys fail extraction instead of leaving stale documentation behind. + +## Next.js + +Wrap `next.config.ts` with `withPermix` from `permix/next/config`. Development watches by default; production performs one scan before Next compiles. + +```ts title="next.config.ts" +import { withPermix } from 'permix/next/config' +import { permissionMetadata } from './src/permission-metadata' + +export default withPermix( + { + reactStrictMode: true, + }, + { + metadata: permissionMetadata, + moduleOutput: 'src/permissions.generated.ts', + } +) +``` + +For plugin composition, `createPermixPlugin(options)` returns a preconfigured `withPermix` function. + +## Renames and removals + +Removing a marker removes it from the next TypeScript and JSON artifacts. Nothing else is deleted. Before renaming or removing a permission, separately review persisted roles, policy providers, SQL seeds, RLS policies, and audit integrations. + +Provider adapters can compare their operation keys with the catalog by calling `validatePermissionCoverage()` from `permix/extractor`. It reports both unknown provider keys and catalog permissions with no provider coverage. diff --git a/docs/content/docs/guide/hydration.mdx b/docs/content/docs/guide/hydration.mdx index 432ed4e7..5bd66089 100644 --- a/docs/content/docs/guide/hydration.mdx +++ b/docs/content/docs/guide/hydration.mdx @@ -9,17 +9,18 @@ Hydration is the process of converting server-side state into client-side state. Note that function-based permissions will be converted to `false` during - dehydration since functions cannot be serialized to JSON. You should call - `setup` method on the client side after hydration to fully restore - function-based permissions. + dehydration since functions cannot be serialized to JSON. Call `install( + {rules})` (or `setup()`) on the client after hydration to restore + function-based permissions. `hydrate()` does not fire the `setup` hook. ## Usage -Permix provides two instance methods for handling hydration: +Permix provides `dehydrate()`, `hydrate()`, and `install()`: - `dehydrate()` - Converts the current permissions state into a JSON-serializable format -- `hydrate(state)` - Restores permissions from a previously dehydrated state +- `hydrate(state)` - Restores booleans from a previously dehydrated state (`isReady()` stays false; no `setup` hook) +- `install({ dehydrated, rules })` - One-shot client entry: dehydrated booleans and/or function rules ```ts twoslash import { createPermix } from 'permix' diff --git a/docs/content/docs/guide/instance.mdx b/docs/content/docs/guide/instance.mdx index 8b682504..77b9aace 100644 --- a/docs/content/docs/guide/instance.mdx +++ b/docs/content/docs/guide/instance.mdx @@ -73,11 +73,68 @@ const canEdit = permix.check('post.edit') // false const canEditWithPost = permix.check('post.edit', somePost) // true ``` +#### `schema` + + + Not required. Alternative to `type` when you already have a Zod (or Valibot, + ArkType, …) schema. + + +Pass a [Standard Schema](https://standardschema.dev/) on the action spec. Entity data is inferred from the schema's output type, so you do not need a separate `interface Post`. If both `type` and `schema` are set, `type` wins. + +```ts twoslash title="/lib/permix-schema.ts" +import { action, createPermix } from 'permix' +import { z } from 'zod' + +const postSchema = z.object({ + id: z.string(), + author: z.string(), +}) + +const permix = createPermix<{ + post: [{ name: 'edit'; schema: typeof postSchema }] +}>() + +permix.setup({ + post: { + edit: (post) => post?.author === 'John Doe', + // ^? + }, +}) +``` + +`action(name, schema, { required }?)` builds the same spec from a value. Use `as const` on the definition so action names stay literal: + +```ts twoslash title="/lib/permix-action.ts" +import { action, createPermix } from 'permix' +import { z } from 'zod' + +const postSchema = z.object({ + id: z.string(), + authorId: z.string(), +}) + +const definition = { + post: ['create', action('edit', postSchema, { required: true })], +} as const + +const permix = createPermix() + +permix.setup({ + post: { + create: true, + edit: (post) => post.authorId === 'user-1', + }, +}) +``` + +To generate a whole CRUD tree from a map of schemas, see the [Standard Schema](/docs/integrations/standard-schema) integration. The factory can also parse `check()` data at runtime with `{ validate: 'deny' | 'throw' }`. + #### `required` Not required, defaults to `false`. -By default, when an action declares a `type`, the data argument in `check` is optional. Set `required: true` to require data for that action. +By default, when an action declares a `type` (or `schema`), the data argument in `check` is optional. Set `required: true` to require data for that action. ```ts twoslash title="/lib/permix-type-required.ts" import { createPermix } from 'permix' diff --git a/docs/content/docs/guide/ready.mdx b/docs/content/docs/guide/ready.mdx index 2edf128b..58b04514 100644 --- a/docs/content/docs/guide/ready.mdx +++ b/docs/content/docs/guide/ready.mdx @@ -8,9 +8,9 @@ description: Learn how to use the `isReady()` method to check if permissions are Sometimes you need to know when permissions are ready to use. For example, you might want to wait for permissions to be ready before rendering a component. That's where the `isReady()` and `isReadyAsync()` methods come in. - `hydrate()` alone does **not** make the instance ready. You must call - `setup()` after hydration to restore function-based rules and mark the - instance as ready. + `hydrate()` alone does **not** make the instance ready. Call `install({rules} + )` or `setup()` after hydration to restore function-based rules and mark the + instance as ready. Boolean `check()` still works on a hydrated instance. ## Usage @@ -91,18 +91,18 @@ const clientPermix = createPermix<{ clientPermix.hydrate(state) console.log(clientPermix.isReady()) // false -// Boolean rules from hydration work immediately: console.log(clientPermix.check('post.read')) // false -// Restore function-based rules and mark the instance ready -clientPermix.setup({ - post: { - read: (post) => !!post?.isPublic, +const ready = clientPermix.install({ + rules: { + post: { + read: (post) => !!post?.isPublic, + }, }, }) -console.log(clientPermix.isReady()) // true -console.log(clientPermix.check('post.read', { isPublic: true })) // true +console.log(ready.isReady()) // true +console.log(ready.check('post.read', { isPublic: true })) // true ``` diff --git a/docs/content/docs/guide/setup.mdx b/docs/content/docs/guide/setup.mdx index 4b95750d..a71251a9 100644 --- a/docs/content/docs/guide/setup.mdx +++ b/docs/content/docs/guide/setup.mdx @@ -5,7 +5,22 @@ description: Learn how to setup permissions in your project ## Overview -After creating Permix instance, you need to define permissions with `setup` method. You can call `setup` in any time with any permissions and Permix will replace the previous permissions. +After creating a Permix factory, call `setup()` to get a frozen instance. `setup()` does not mutate the factory — overlapping calls return isolated instances. + +```ts +const permix = createPermix<{ + post: ['create'] + comment: ['create', 'update'] +}>().setup({ + post: { + create: true, + }, + comment: { + create: true, + update: true, + }, +}) +``` You always should describe all permissions in the `setup` method that was @@ -23,9 +38,7 @@ After creating Permix instance, you need to define permissions with `setup` meth const permix = createPermix<{ post: ['create'] comment: ['create', 'update'] -}>() - -permix.setup({ +}>().setup({ post: { create: true, }, @@ -77,7 +90,7 @@ permix.isReady() // true ## Type-Based -When creating a Permix instance, you can attach a `type` to actions that need entity data in rule callbacks. This allows you to check permissions for specific data entities. So instead of `boolean` you can use functions to check permissions. +When creating a Permix instance, you can attach a `type` to actions that need entity data in rule callbacks. This allows you to check permissions for specific data entities. So instead of `boolean` you can use functions to check permissions. Functions may return `true` / `false` or `{ allow, reason }` — `check()` still collapses the result to a boolean; `explain()` keeps the reason. See [Check](/docs/guide/check#explain). ```ts twoslash import { createPermix } from 'permix' @@ -105,6 +118,30 @@ permix.setup({ }) ``` +### Standard Schema + +If the entity already has a Zod (or Valibot, ArkType, …) schema, pass `schema` instead of `type`. See [Standard Schema](/docs/integrations/standard-schema) and [`action()`](/docs/guide/instance#schema). + +```ts twoslash +import { createPermix } from 'permix' +import { z } from 'zod' + +const postSchema = z.object({ + id: z.string(), + authorId: z.string(), +}) + +const permix = createPermix<{ + post: [{ name: 'update'; schema: typeof postSchema }] +}>() + +permix.setup({ + post: { + update: (post) => post?.authorId === 'user-1', + }, +}) +``` + ### Required By default, a `type` on an action makes the data argument optional in `check`. Set `required: true` to require it. diff --git a/docs/content/docs/index.mdx b/docs/content/docs/index.mdx index 50db3dba..b3a03e86 100644 --- a/docs/content/docs/index.mdx +++ b/docs/content/docs/index.mdx @@ -36,14 +36,40 @@ When creating Permix, the goal was to simplify DX as much as possible without lo That is why you only need to write the following code to get started: +```ts +import { permission } from 'permix' + +export const canReadPost = permission({ + key: 'post.read', + title: 'Read posts', +}) +``` + +```bash +pnpm permix extract +``` + +```ts +import { createPermix } from 'permix' +import { type Definition, permissions } from './.permix/permissions' + +const permix = createPermix().setup({ + post: { + read: true, + }, +}) + +const canRead = permix.check(permissions.post.read) // true +``` + +Mark paths with `permission()`, generate a typed catalog with `permix extract`, then pass `Definition` to `createPermix`. Manual generics stay as the escape hatch when keys are dynamic: + ```ts twoslash import { createPermix } from 'permix' const permix = createPermix<{ post: ['read'] -}>() - -permix.setup({ +}>().setup({ post: { read: true, }, @@ -52,6 +78,8 @@ permix.setup({ const canReadPost = permix.check('post.read') // true ``` +Core `setup()` returns a frozen instance and does not mutate the factory. It is fine to keep a factory at module scope in a SPA; on the server, stash the **returned** instance per request. Use an adapter `setupMiddleware` (or `createPermix().setup(rules)` inside the request). + It looks too simple, so here's a more interesting example: ```ts twoslash @@ -138,14 +166,14 @@ async function setupPermix() { user: () => userPermissions(user), } - permix.setup(permissionsMap[user.role]()) + return permix.setup(permissionsMap[user.role]()) } // Call setupPermix where you need to setup permissions -setupPermix() +const instance = await setupPermix() // Check if a user has permission to do something -const canCreatePost = permix.check('post.create') +const canCreatePost = instance.check('post.create') async function getComment() { // Imagine that this function is fetching comment from database @@ -158,7 +186,7 @@ async function getComment() { const comment = await getComment() -const canUpdateComment = permix.check('comment.update', comment) +const canUpdateComment = instance.check('comment.update', comment) ``` ## Benefits @@ -166,6 +194,7 @@ const canUpdateComment = permix.check('comment.update', comment) What are the benefits of using Permix? - 100% type-safe without writing TypeScript (except for initialization) +- Requires TypeScript 5.9 or newer (5.9, 6, and 7 are supported) - Single source of truth for your entire app - Perfect match for TypeScript monorepos - Zero dependencies diff --git a/docs/content/docs/integrations/astro.mdx b/docs/content/docs/integrations/astro.mdx new file mode 100644 index 00000000..a23c7703 --- /dev/null +++ b/docs/content/docs/integrations/astro.mdx @@ -0,0 +1,233 @@ +--- +title: Astro +description: Learn how to use Permix with Astro +--- + +## Overview + +Permix provides middleware for [Astro](https://astro.build/) through `permix/astro`. It stores a per-request instance on `context.locals`, so Astro middleware, endpoints, and server-rendered pages share the same rules. + +UI checks in islands reuse the existing [`permix/react`](/docs/integrations/react), [`permix/vue`](/docs/integrations/vue), [`permix/solid`](/docs/integrations/solid), or [`permix/svelte`](/docs/integrations/svelte) integrations. Dehydrate on the server and hydrate in the island with `PermixHydrate`. + + + Before getting started with the Astro integration, make sure you've completed + the initial setup steps in the [Quick Start](/docs/quick-start) guide. + + + + + + +## Setup + +Create a Permix instance and run `setupMiddleware` from `src/middleware.ts`: + +```ts title="src/lib/permix.ts" +import { createPermix } from 'permix/astro' + +interface Post { + id: string + authorId: string +} + +export const permix = createPermix<{ + post: [ + { name: 'create'; type: Post }, + { name: 'read'; type: Post }, + { name: 'update'; type: Post }, + { name: 'delete'; type: Post }, + ] +}>() +``` + +```ts title="src/middleware.ts" +import { defineMiddleware } from 'astro:middleware' +import { permix } from './lib/permix' + +export const onRequest = defineMiddleware( + permix.setupMiddleware(({ request }) => { + const isAdmin = request.headers.get('x-user-role') === 'admin' + + return { + post: { + create: true, + read: true, + update: isAdmin, + delete: isAdmin, + }, + } + }) +) +``` + +`setupMiddleware` is compatible with [`sequence`](https://docs.astro.build/en/guides/middleware/#chaining-middleware) if you already have other middleware. + +The instance lives on `locals`, not on the `Request` object. + + + + + +## Checking permissions + +Use `checkMiddleware` to guard an endpoint, or `getOrThrow` inside the handler for entity-aware checks: + +```ts title="src/pages/api/posts.ts" +import type { APIRoute } from 'astro' +import { permix } from '../../lib/permix' + +export const POST: APIRoute = (context) => + permix.checkMiddleware('post.create')(context, async () => { + return Response.json({ ok: true }) + }) +``` + +```ts title="src/pages/api/posts/[id].ts" +import type { APIRoute } from 'astro' +import { permix } from '../../../lib/permix' + +export const PATCH: APIRoute = async (context) => { + const post = await getPost(context.params.id) + + if (!permix.getOrThrow(context).check('post.update', post)) { + return Response.json({ error: 'Forbidden' }, { status: 403 }) + } + + return Response.json({ ok: true }) +} +``` + +`checkMiddleware` accepts the same arguments as the core `check`: a path, a path plus entity data, or a callback. + +Denied requests default to `403` with `{ error: 'Forbidden' }`. Customize with `onForbidden` in `createPermix` options. + + + + + +## Pages and islands + +In a `.astro` page, read the instance from `Astro.locals` (or pass `Astro` as the context — `get` accepts either): + +```astro title="src/pages/posts/[id].astro" +--- +import { permix } from '../../lib/permix' + +const post = await getPost(Astro.params.id) + +if (!permix.getOrThrow(Astro).check('post.read', post)) { + return Astro.redirect('/404') +} + +const state = permix.getOrThrow(Astro).dehydrate() +--- + + +``` + +Hydrate the island with the matching UI adapter: + +```tsx title="src/components/EditButton.tsx" +import { createPermix } from 'permix' +import { PermixHydrate, PermixProvider, usePermix } from 'permix/react' + +const permix = createPermix<{ + post: [ + { name: 'create'; type: { id: string; authorId: string } }, + { name: 'read'; type: { id: string; authorId: string } }, + { name: 'update'; type: { id: string; authorId: string } }, + { name: 'delete'; type: { id: string; authorId: string } }, + ] +}>() + +export function EditButton({ + post, + state, +}: { + post: { id: string; authorId: string } + state: ReturnType +}) { + return ( + + + + + + ) +} + +function Inner({ post }: { post: { id: string; authorId: string } }) { + const { check } = usePermix(permix) + if (!check('post.update', post)) return null + return +} +``` + + + `hydrate()` restores booleans but does not restore function-based rules or + mark the instance ready. Call `permix.setup(...)` on the client with the full + rule set (including closures) after hydration. See the [Hydration + guide](/docs/guide/hydration). + + + + + + +## Templates + +```ts title="src/lib/permix.ts" +import { createPermix } from 'permix/astro' + +export const permix = createPermix<{ + post: ['create', 'read', 'update', 'delete'] +}>() + +export const adminTemplate = permix.template({ + post: { create: true, read: true, update: true, delete: true }, +}) + +export const guestTemplate = permix.template({ + post: { create: false, read: true, update: false, delete: false }, +}) +``` + +```ts title="src/middleware.ts" +import { defineMiddleware } from 'astro:middleware' +import { adminTemplate, guestTemplate, permix } from './lib/permix' + +export const onRequest = defineMiddleware( + permix.setupMiddleware(({ request }) => { + const isAdmin = request.headers.get('x-user-role') === 'admin' + return isAdmin ? adminTemplate() : guestTemplate() + }) +) +``` + + + + + +## Example + +You can find a runnable example of the Astro integration [here](https://github.com/letstri/permix/tree/main/examples/astro). + + + + + +## API + +### `createPermix(options?)` + +Returns an object with the following methods: + +| Method | Description | +| --- | --- | +| `setupMiddleware(rules \| callback)` | Astro middleware that creates a per-request instance on `locals`. | +| `checkMiddleware(...args)` | Astro middleware that allows or returns the `onForbidden` response. | +| `get(context \| locals)` | Return the instance, or `null`. | +| `getOrThrow(context \| locals)` | Return the instance, or throw `PermixNotFoundError`. | +| `getRules(context \| locals)` | Return the current rules, or `null`. | +| `template(rules)` | Create a reusable rule set. Same as the core [`template`](/docs/guide/template). | +| `contextKey(key)` | Store this factory under a custom `locals` key. | diff --git a/docs/content/docs/integrations/drizzle.mdx b/docs/content/docs/integrations/drizzle.mdx index 36e915f9..4becefc0 100644 --- a/docs/content/docs/integrations/drizzle.mdx +++ b/docs/content/docs/integrations/drizzle.mdx @@ -7,14 +7,7 @@ description: Auto-generate a Permix definition and CRUD rules from your Drizzle Permix ships with a Drizzle integration that takes your existing Drizzle schema and produces a fully type-safe Permix instance with one permission entity per table. By default each table receives the four CRUD actions (`create`, `read`, `update`, `delete`), but the action list can be customised. -Two entry points are exposed so you can choose the one that matches your installed Drizzle version: - -| Import path | Drizzle version | Notes | -| --- | --- | --- | -| `permix/drizzle` | **v1** (`>=1.0.0-rc`) | Uses Drizzle's official `extractTablesFromSchema` helper. Detects tables and views. | -| `permix/drizzle/legacy` | **v0** (`>=0.30 <1`) | Detects tables only via `is(value, Table)`. | - -Both entry points expose the same API surface, so migrating between them is a one-line import change once you upgrade Drizzle. +Import from `permix/drizzle`. It requires Drizzle ORM **v1** (`extractTablesFromSchema`) and detects tables and views. Before getting started with the Drizzle integration, make sure you've @@ -30,9 +23,7 @@ Both entry points expose the same API surface, so migrating between them is a on Define your Drizzle schema as usual, then create a Permix instance from it. -### Drizzle v1 - -```ts twoslash title="/lib/drizzle-v1.ts" +```ts twoslash title="/lib/drizzle.ts" import { defineRelations } from 'drizzle-orm' import { integer, pgTable, serial, text } from 'drizzle-orm/pg-core' import { createPermix } from 'permix/drizzle' @@ -67,44 +58,7 @@ permix.check('users.read') // true permix.check('posts.delete') // false ``` -### Drizzle v0 - -```ts twoslash title="/lib/drizzle-legacy.ts" -import { relations } from 'drizzle-orm/_relations' -import { integer, pgTable, serial, text } from 'drizzle-orm/pg-core' -import { createPermix } from 'permix/drizzle/legacy' - -const users = pgTable('users', { - id: serial('id').primaryKey(), - name: text('name').notNull(), -}) - -const posts = pgTable('posts', { - id: serial('id').primaryKey(), - title: text('title').notNull(), - authorId: integer('author_id') - .notNull() - .references(() => users.id), -}) - -const usersRelations = relations(users, ({ many }) => ({ - posts: many(posts), -})) - -const schema = { users, posts, usersRelations } - -const permix = createPermix(schema) - -permix.setup({ - users: { create: true, read: true, update: false, delete: false }, - posts: { create: true, read: true, update: true, delete: false }, -}) - -permix.check('users.read') // true -permix.check('posts.delete') // false -``` - -The returned instance is a regular Permix object, so every API you already know — `check`, `setup`, `template`, `dehydrate`, `hydrate`, `hook`, `isReady`, etc. — is available unchanged. +The returned instance is a regular Permix object, so every API you already know — `check`, `setup`, `template`, `dehydrate`, `hydrate`, `install`, `hook`, `isReady`, etc. — is available unchanged. @@ -149,8 +103,8 @@ permix.actions // readonly ['create', 'read', 'update', 'delete'] ## Notes -- **v1** detects both tables **and views** (via Drizzle's `extractTablesFromSchema`). **v0** detects tables only. -- Both versions skip non-entity exports automatically, so `import * as schema` works as-is. In v1 a `defineRelations(...)` object is filtered out; in v0 the same is true of `relations(...)` calls. +- Detects both tables **and views** via Drizzle's `extractTablesFromSchema`. +- Non-entity exports are skipped automatically, so `import * as schema` works as-is (`defineRelations(...)` objects are filtered out). - Tables from any Drizzle dialect (`drizzle-orm/pg-core`, `drizzle-orm/mysql-core`, `drizzle-orm/sqlite-core`) are detected, so a single schema can mix dialects if needed. -- Drizzle is declared as an optional peer dependency. You only need to install it if you plan to use a `permix/drizzle*` entry point. +- Drizzle is declared as an optional peer dependency. You only need to install it if you plan to use `permix/drizzle`. - The permission keys default to the **schema export names**, not the SQL table names. If you exported `pgTable('app_users', { ... })` as `users`, you check `users.read`, not `app_users.read`. diff --git a/docs/content/docs/integrations/elysia.mdx b/docs/content/docs/integrations/elysia.mdx index 44705098..0ddc0f33 100644 --- a/docs/content/docs/integrations/elysia.mdx +++ b/docs/content/docs/integrations/elysia.mdx @@ -232,6 +232,7 @@ The `onForbidden` handler receives: - `context`: Elysia Context object - `path`: The permission path that was checked (or `null` for callback checks) - `data`: Optional entity data passed to the check +- `reasons`: Denial strings from `{ allow: false, reason }` rules (empty for boolean denials) ## Advanced Usage diff --git a/docs/content/docs/integrations/express.mdx b/docs/content/docs/integrations/express.mdx index 927a9ff1..421d3ff1 100644 --- a/docs/content/docs/integrations/express.mdx +++ b/docs/content/docs/integrations/express.mdx @@ -204,6 +204,7 @@ The `onForbidden` handler receives: - `res`: Express Response object - `path`: The permission path that was checked (or `null` for callback checks) - `data`: Optional entity data passed to the check +- `reasons`: Denial strings from `{ allow: false, reason }` rules (empty for boolean denials) ## Advanced Usage diff --git a/docs/content/docs/integrations/fastify.mdx b/docs/content/docs/integrations/fastify.mdx index a262e357..6df2cce4 100644 --- a/docs/content/docs/integrations/fastify.mdx +++ b/docs/content/docs/integrations/fastify.mdx @@ -228,6 +228,7 @@ The `onForbidden` handler receives: - `reply`: Fastify Reply object - `path`: The permission path that was checked (or `null` for callback checks) - `data`: Optional entity data passed to the check +- `reasons`: Denial strings from `{ allow: false, reason }` rules (empty for boolean denials) ## Advanced Usage diff --git a/docs/content/docs/integrations/hono.mdx b/docs/content/docs/integrations/hono.mdx index 502d807c..2b711faf 100644 --- a/docs/content/docs/integrations/hono.mdx +++ b/docs/content/docs/integrations/hono.mdx @@ -221,6 +221,7 @@ The `onForbidden` handler receives: - `c`: Hono Context object - `path`: The permission path that was checked (or `null` for callback checks) - `data`: Optional entity data passed to the check +- `reasons`: Denial strings from `{ allow: false, reason }` rules (empty for boolean denials) ## Advanced Usage diff --git a/docs/content/docs/integrations/nest.mdx b/docs/content/docs/integrations/nest.mdx new file mode 100644 index 00000000..5d00e5ea --- /dev/null +++ b/docs/content/docs/integrations/nest.mdx @@ -0,0 +1,262 @@ +--- +title: NestJS +description: Learn how to use Permix with NestJS +--- + +## Overview + +Permix provides a NestJS integration that sets up permissions per request and enforces them with a guard plus a `@Check` decorator. The factory is created using `createPermix` from `permix/nest`. + + + Before getting started with the NestJS integration, make sure you've completed + the initial setup steps in the [Quick Start](/docs/quick-start) guide. + + + + + + +## Setup + +Create a Permix factory and register its guard as a global `APP_GUARD`. The guard always attaches a per-request instance; it only enforces a permission when `@Check` is present. + +```ts +import { Module } from '@nestjs/common' +import { APP_GUARD } from '@nestjs/core' +import { createPermix } from 'permix/nest' + +interface Post { + id: string + authorId: string + title: string + content: string +} + +export const permix = createPermix<{ + post: [ + { name: 'create'; type: Post }, + { name: 'read'; type: Post }, + { name: 'update'; type: Post }, + ] +}>() + +@Module({ + providers: [ + { + provide: APP_GUARD, + useValue: permix.guard(({ req }) => { + // You can access req.user or other properties to determine permissions + return { + post: { + create: true, + read: true, + update: false, + }, + } + }), + }, + ], +}) +export class AppModule {} +``` + + + The guard works with both the Express and Fastify Nest HTTP adapters. It + preserves full type safety from your Permix definition. + + + + + + +## Checking Permissions + +Use the `Check` decorator on a handler or controller: + +```ts +import { Controller, Delete, Get, Post, Put } from '@nestjs/common' +import { permix } from './permix' + +@Controller('posts') +export class PostsController { + @Post() + @permix.Check('post.create') + create() { + return { success: true } + } + + @Put(':id') + @permix.Check((c) => c('post.read') && c('post.update')) + update() { + return { success: true } + } + + @Delete(':id') + @permix.Check('post.~all') + remove() { + return { success: true } + } + + @Get() + @permix.Check('post.~any') + findAll() { + return { posts: getAllPosts() } + } +} +``` + + + + + +## Accessing Permix Directly + +You can access the Permix instance directly in your route handlers using the `get` function: + +```ts +@Get() +findAll(@Req() req: Request) { + const { check } = permix.getOrThrow(req) + + if (check('post.read')) { + return { posts: getAllPosts() } + } + + throw new ForbiddenException({ + error: 'You do not have permission to read posts', + }) +} +``` + +Entity-based (ReBAC) checks usually run in the handler after the resource is loaded: + +```ts +@Put(':id') +async update(@Param('id') id: string, @Req() req: Request) { + const post = await getPostById(id) + const { check } = permix.getOrThrow(req) + + if (!check('post.update', post)) { + throw new ForbiddenException({ error: 'You cannot update this post' }) + } + + return { success: true } +} +``` + + + + + +## Using Templates + +Permix provides a template helper to create reusable permission rule sets: + +```ts +const adminTemplate = permix.template({ + post: { + create: true, + read: true, + update: true, + }, +}) + +{ + provide: APP_GUARD, + useValue: permix.guard(({ req }) => { + if (req.user?.role === 'admin') { + return adminTemplate() + } + + return { + post: { + create: false, + read: true, + update: false, + }, + } + }), +} +``` + + + + + +## Custom Error Handling + +By default, a denied `@Check` throws a Nest `ForbiddenException` with `{ error: 'Forbidden' }`. You can customize this by providing an `onForbidden` handler: + +### Basic Error Handler + +```ts +const permix = createPermix({ + onForbidden: () => { + throw new ForbiddenException({ + error: 'Custom forbidden message', + }) + }, +}) +``` + +### Dynamic Error Handler + +You can also throw different responses based on the checked path: + +```ts +const permix = createPermix({ + onForbidden: ({ path }) => { + if (path === 'post.create') { + throw new ForbiddenException({ + error: `You don't have permission for ${path}`, + }) + } + + throw new ForbiddenException({ + error: 'You do not have permission to perform this action', + }) + }, +}) +``` + +The `onForbidden` handler receives: + +- `req`: The HTTP request object (Express or Fastify) +- `context`: Nest `ExecutionContext` +- `path`: The permission path that was checked (or `null` for callback checks) +- `data`: Optional entity data passed to the check +- `reasons`: Denial strings from `{ allow: false, reason }` rules (empty for boolean denials) + +## Advanced Usage + +### Async Permission Rules + +You can use async functions in your guard setup: + +```ts +permix.guard(async ({ req }) => { + const userPermissions = await getUserPermissions(req.user.id) + + return { + post: { + create: userPermissions.canCreatePosts, + read: userPermissions.canReadPosts, + update: userPermissions.canUpdatePosts, + }, + } +}) +``` + +### Hooks + +You can register hooks at the factory level to listen for events across all requests: + +```ts +permix.hook('check', ({ path, data }) => { + console.log(`Permission checked: ${path}`, data) +}) +``` + +### Example + +You can find the example of the NestJS integration [here](https://github.com/letstri/permix/tree/main/examples/nest). diff --git a/docs/content/docs/integrations/next.mdx b/docs/content/docs/integrations/next.mdx index 5f1f1204..6f547b88 100644 --- a/docs/content/docs/integrations/next.mdx +++ b/docs/content/docs/integrations/next.mdx @@ -5,244 +5,248 @@ description: Learn how to use Permix with Next.js App Router ## Overview -Permix provides a dedicated integration for the Next.js **App Router** through `permix/next`. It exposes a `createPermix` factory that returns a **per-request** Permix instance backed by React's [`cache()`](https://react.dev/reference/react/cache), so you can `setup()` the rules once and `check()` them anywhere on the server — layouts, pages, route handlers, and server actions — without threading the instance through props. +`permix/next` is a request-safe helper for the Next.js **App Router**. You pass a rules resolver into `createPermix`. React's [`cache()`](https://react.dev/reference/react/cache) memoizes **one Promise** of a fully initialized core instance per request, so concurrent Server Components share the same setup instead of racing layout mutation. -The client side reuses the existing React integration (`permix/react`): the server `dehydrate()`s its state and the client hydrates it into its own singleton via `PermixProvider` + `PermixHydrate`. +The facade exposes **dual access** to that Promise: + +- Async `getPermix` / `check` / `getRules` / `dehydrate` for async Server Components +- `usePermix()` for non-async Server Components — it unwraps the same Promise with React [`use()`](https://react.dev/reference/react/use). `check()` stays synchronous at the call site; React may suspend only while rules resolve + +Sync vs async is an **API** choice, not the PPR boundary. A permission UI can prerender after either path when its policy inputs are static or cached. User, session, or cookie-dependent outcomes must stay behind Suspense and must not enter a shared build-time shell. + +The client still uses `permix/react`. Hydrate only the permission-dependent islands — do not block the static App Shell on dehydrated state. - Before getting started with the Next.js integration, make sure you've - completed the initial setup steps in the [Quick Start](/docs/quick-start) - guide. Familiarity with the [Hydration guide](/docs/guide/hydration) helps - too. + Complete the [Quick Start](/docs/quick-start) and read the [Hydration + guide](/docs/guide/hydration) before this page. + + + + To generate typed permission constants before Next compiles, use `withPermix` + from `permix/next/config`. See [Permission + extraction](/docs/guide/extraction). - This integration is designed for the **App Router**. It relies on `react`'s - request-scoped `cache()`, which is available in server components, route - handlers, and server actions within a single request. + Requires **Next.js 15+**. Route Handlers and Server Actions do **not** share + the RSC `cache()` identity. Create and `setup()` an explicit core + `createPermix()` instance inside each invocation. -## Define your permissions - -Create a Permix instance once in a shared module so it can be imported anywhere on the server: +## Define the resolver ```ts title="lib/permix.ts" import { createPermix } from 'permix/next' - -interface Post { - id: string - authorId: string -} +import { getSession } from '@/lib/auth' export const permix = createPermix<{ post: [ - { name: 'create'; type: Post }, - { name: 'read'; type: Post }, - { name: 'update'; type: Post }, - { name: 'delete'; type: Post }, + { name: 'create'; type: { id: string; authorId: string } }, + { name: 'read'; type: { id: string; authorId: string } }, + { name: 'update'; type: { id: string; authorId: string } }, + { name: 'delete'; type: { id: string; authorId: string } }, ] -}>() -``` - -The returned helper does **not** hold any permission state at module scope — every request gets its own isolated instance. - - - - - -## Setup per request - -Call `setup()` early in the request lifecycle. A common place is the root layout (or any server component that runs before the ones doing `check`s). Resolve any async data (session, headers, cookies, DB lookups) first, then pass plain rules to `setup`: - -```tsx title="app/layout.tsx" -import { permix } from '@/lib/permix' -import { getSession } from '@/lib/auth' - -export default async function RootLayout({ - children, -}: { - children: React.ReactNode -}) { +}>(async () => { const session = await getSession() - - permix.setup({ + return { post: { create: !!session, read: true, update: (post) => post?.authorId === session?.userId, delete: session?.role === 'admin', }, - }) - - return ( - - {children} - - ) -} + } +}) ``` - - Because `setup()` is scoped to the current request, calling it again from a - nested server component or route handler in the **same** request simply - replaces the rules for that request. Other requests are unaffected. - +The helper holds no permission state at module scope. The first caller in a request starts initialization; every other caller awaits the same Promise. + +Keep layouts and pages **synchronous**. Move async permission and data work into feature-owned Server Components, and put shaped skeletons behind **page-owned** Suspense so static chrome remains in the App Shell. -## Check on the server - -Anywhere a server component, route handler, or server action runs in that request, you can use `check()`: +## Async Server Components ```tsx title="app/posts/[id]/page.tsx" import { notFound } from 'next/navigation' +import { Suspense } from 'react' import { permix } from '@/lib/permix' import { getPost } from '@/lib/posts' -export default async function PostPage({ +export default function PostPage({ params, }: { params: Promise<{ id: string }> }) { - const { id } = await params - const post = await getPost(id) + return ( +
+

Post

+ }> + {params.then(({ id }) => ( + + ))} + +
+ ) +} - if (!permix.check('post.read', post)) { +async function PostArticle({ id }: { id: string }) { + const post = await getPost(id) + if (!post || !(await permix.check('post.read', post))) { notFound() } - - return
{/* ... */}
+ return
{post.id}
} ``` -```ts title="app/api/posts/route.ts" +`await permix.getPermix()` returns the initialized core instance when you need `isReady()` or multiple synchronous `check()` calls after the await. + +
+ + + +## Non-async Server Components + +```tsx title="app/features/sync-read-badge.tsx" import { permix } from '@/lib/permix' -export async function POST(req: Request) { - if (!permix.check('post.create')) { - return Response.json({ error: 'Forbidden' }, { status: 403 }) +export function SyncReadBadge() { + const instance = permix.usePermix() + return {instance.check('post.read') ? 'allowed' : 'denied'} +} +``` + +Place this behind Suspense. `usePermix()` from `permix/next` is for Server Components. Client components keep using `usePermix` from `permix/react`. + + + + + +## Prerender-safe vs request-specific rules + +| Outcome | When | Where it lives | +| --- | --- | --- | +| Globally cacheable check | Policy inputs are static or `"use cache"` | Shared App Shell | +| Root-param-keyed check | URL is known (`next/root-params` in **your** resolver); optional `prefetch={true}` | Per-link prefetch | +| Warm `"use cache: private"` payload | Same session already cached/prefetched | Authorized content can commit immediately | +| Cold or deliberately fresh session check | Cookie/session/user data, uncached | Dynamic hole behind Suspense | + +Permix does not import generated root-param getters. For tenant or locale policies, read `next/root-params` in the **app-owned** resolver (the same pattern as next-intl). Cache serializable policy inputs with `"use cache"`, then build any function rules outside that cache boundary. + +Keep ordinary tenant/resource slugs as page values passed through `params.then(...)`. Do not promote a nested slug to a root param solely to avoid prop passing. + + + + + +## Permission-first private payloads + +Check permission **before** loading feature data. Return serializable data or `null` when denied. An uncached caller performs `notFound()` / `redirect()` — denial control flow stays outside the cached payload. Cache tags remain app-owned; Permix cannot infer a feature's data or membership lifecycle. + +```tsx title="app/features/private-edit.tsx" +import { createPermix } from 'permix' +import { getSession } from '@/lib/auth' +import { rulesForSession, type PermissionsDefinition } from '@/lib/permissions' +import { getPost } from '@/lib/posts' + +async function readPostUpdatePayload(postId: string) { + 'use cache: private' + const session = await getSession() + const permix = createPermix() + permix.setup(rulesForSession(session)) + const post = await getPost(postId) + if (!post || !permix.check('post.update', post)) { + return null } + return { id: post.id, authorId: post.authorId } +} - // create the post... - return Response.json({ ok: true }) +export async function PrivateEditIsland({ postId }: { postId: string }) { + const payload = await readPostUpdatePayload(postId) + if (!payload) { + return null + } + return } ``` -You can also reach the underlying core instance through `permix.get()` if you need methods like `isReady()` or `getRules()`. +A client `check()` after hydrate is a **UI hint**, not enforcement. Authoritative decisions stay on the server (this payload, Route Handlers, Server Actions). -## Send permissions to the client +## Hydrate permission-dependent UI only -Use `dehydrate()` to serialize the request's permissions and hand them to a client provider. The server cannot send the Permix instance itself across the boundary — only the JSON state. +Create the client bindings with `createPermix` from `permix/react` — same factory name as the server adapter, different import path: + +```ts title="lib/client-permix.ts" +import { createPermix } from 'permix/react' +import type { PermissionsDefinition } from './permissions' + +export const { + permix: clientPermix, + PermixProvider, + PermixHydrate, + usePermix, + Check, +} = createPermix() +``` ```tsx title="app/providers.tsx" 'use client' -import { createPermix } from 'permix' -import { PermixHydrate, PermixProvider } from 'permix/react' import type { DehydratedState } from 'permix' - -// One singleton per browser tab. The same type definition as on the server. -const permix = createPermix<{ - post: [ - { name: 'create'; type: { id: string; authorId: string } }, - { name: 'read'; type: { id: string; authorId: string } }, - { name: 'update'; type: { id: string; authorId: string } }, - { name: 'delete'; type: { id: string; authorId: string } }, - ] -}>() +import { PermixHydrate, PermixProvider } from '@/lib/client-permix' +import type { PermissionsDefinition } from '@/lib/permissions' export function Providers({ state, children, }: { - state: DehydratedState + state: DehydratedState children: React.ReactNode }) { return ( - + {children} ) } - -export { permix } -``` - -Then wire it up in your root layout right after `setup()`: - -```tsx title="app/layout.tsx" -import { permix } from '@/lib/permix' -import { getSession } from '@/lib/auth' -import { Providers } from './providers' - -export default async function RootLayout({ - children, -}: { - children: React.ReactNode -}) { - const session = await getSession() - - permix.setup({/* ...rules derived from session... */}) - - return ( - - - {children} - - - ) -} ``` - - `hydrate()` restores the boolean state but does not flip `isReady` on its own - — function-based rules are lost during serialization. If you need `isReady` on - the client (e.g. to gate UI on `usePermix(...).isReady`), call - `permix.setup(...)` on the client too with the same shape (using booleans and - any function rules you want active client-side). See the [Hydration - guide](/docs/guide/hydration) for details. - +Await `permix.dehydrate()` in the feature that owns the island, not in the root layout. `hydrate()` restores booleans and does not set `isReady`. Call `clientPermix.setup(...)` on the client for function rules — see [Hydration](/docs/guide/hydration). -## Use on the client - -From any client component, import the singleton from `app/providers.tsx` and the hooks/components from `permix/react`: - -```tsx title="app/posts/[id]/edit-button.tsx" -'use client' +## Route Handlers and Server Actions -import { usePermix } from 'permix/react' -import { permix } from '@/app/providers' +```ts title="app/api/posts/route.ts" +import { createPermix } from 'permix' +import { getSession } from '@/lib/auth' +import { rulesForSession, type PermissionsDefinition } from '@/lib/permissions' -export function EditButton({ - post, -}: { - post: { id: string; authorId: string } -}) { - const { check } = usePermix(permix) +export async function POST() { + const permix = createPermix() + permix.setup(rulesForSession(await getSession())) - if (!check('post.update', post)) { - return null + if (!permix.check('post.create')) { + return Response.json({ error: 'Forbidden' }, { status: 403 }) } - return + return Response.json({ ok: true }) } ``` -If you prefer the component API, create checkers with `createComponents` from `permix/react` and use them in your client components — see the [React integration](/docs/integrations/react#components) for details. +Share `rulesForSession` (or templates) with the RSC resolver so the policy stays in one place. @@ -250,32 +254,15 @@ If you prefer the component API, create checkers with `createComponents` from `p ## Templates -`createPermix` exposes the same `template()` helper as the core API for reusing rule sets: +`template()` does not wait on initialization: ```ts title="lib/permix.ts" -import { createPermix } from 'permix/next' - -export const permix = createPermix<{ - post: ['create', 'read', 'update', 'delete'] -}>() - export const adminTemplate = permix.template({ post: { create: true, read: true, update: true, delete: true }, }) - -export const guestTemplate = permix.template({ - post: { create: false, read: true, update: false, delete: false }, -}) ``` -```tsx title="app/layout.tsx" -import { permix, adminTemplate, guestTemplate } from '@/lib/permix' -import { getSession } from '@/lib/auth' - -const session = await getSession() - -permix.setup(session?.role === 'admin' ? adminTemplate() : guestTemplate()) -``` +You can also use `createTemplate` from `permix` in a shared module imported by the resolver, handlers, and the client. @@ -283,47 +270,34 @@ permix.setup(session?.role === 'admin' ? adminTemplate() : guestTemplate()) ## Example -You can find a runnable example of the Next.js integration [here](https://github.com/letstri/permix/tree/main/examples/next). +Runnable App Router example with Cache Components and Partial Prefetching: [examples/next](https://github.com/letstri/permix/tree/main/examples/next).
-## How per-request isolation works - -`createPermix` from `permix/next` wraps a single core instance per request using React's `cache()`. Inside one Next.js request: - -- The first call to `setup`/`check`/`get`/`dehydrate` creates (or reuses) **one** instance. -- All subsequent calls in the same request — across server components, route handlers, and server actions — share that instance. - -Across concurrent requests, each request gets its **own** instance. State never leaks between users. - - - Do **not** store the result of `permix.get()` (or any rule data) in - module-level variables. That would defeat per-request isolation. Always go - through `permix.check()` / `permix.get()` so the request-scoped cache is - consulted. - - ## API -### `createPermix()` +### `createPermix(resolveRules)` -Returns an object with the following methods: +`resolveRules` is `() => Rules | Promise>`. | Method | Description | | --- | --- | -| `setup(rules)` | Set the per-request permission rules. Resolve any async data (session, etc.) before calling. | -| `check(...args)` | Check a permission against the current request's rules. Same signature as the core `check`. | -| `get()` | Return the underlying [`Permix`](/docs/guide/instance) instance for the current request. | -| `getRules()` | Return the current rules object for the request-scoped instance, or `null`. | -| `dehydrate()` | Serialize the current request's rules to JSON (for `` on the client). | -| `template(rules)` | Create a reusable rule set. Same as the core [`template`](/docs/guide/template). | +| `getPermix()` | Promise of the initialized [`Permix`](/docs/guide/instance) for this request | +| `usePermix()` | Same instance via React `use()` (Server Components) | +| `check(...args)` | Async check against the initialized instance | +| `getRules()` | Async current rules, or `null` | +| `dehydrate()` | Async JSON snapshot for `` | +| `template(rules)` | Reusable rule set — same as core [`template`](/docs/guide/template) | + +There is no `setup`, `hook`, or `hookOnce` on the Next facade. Initialization is the resolver. Subscribe to core hooks on the instance from `getPermix()` / `usePermix()` if you need them. ## TanStack Start and other frameworks -The client layer (`permix/react`) is framework-agnostic. If you're using TanStack Start, Remix, or a custom React SSR setup, you can still use `permix/react` on the client. For the server side, either: +The client layer (`permix/react`) is framework-agnostic. If you're using TanStack Start, React Router 7, or a custom React SSR setup, you can still use `permix/react` on the client. For the server side, either: -- Use the dedicated [`permix/tanstack-start`](/docs/integrations/tanstack-start) integration, which follows the same shape as `permix/next`, or +- Use the dedicated [`permix/tanstack-start`](/docs/integrations/tanstack-start) or [`permix/react-router`](/docs/integrations/react-router) integration, which follow the same shape as `permix/next`, or +- For Nuxt, use [`permix/nuxt`](/docs/integrations/nuxt) on the server and `permix/vue` on the client, or - Create a core Permix instance per request manually (see [Hydration guide](/docs/guide/hydration)), or - Use an existing server integration like [`permix/node`](/docs/integrations/node), [`permix/express`](/docs/integrations/express), or [`permix/hono`](/docs/integrations/hono) when applicable. diff --git a/docs/content/docs/integrations/node.mdx b/docs/content/docs/integrations/node.mdx index afdb8812..7701af39 100644 --- a/docs/content/docs/integrations/node.mdx +++ b/docs/content/docs/integrations/node.mdx @@ -233,6 +233,7 @@ The `onForbidden` handler receives: - `res`: Node.js ServerResponse object - `path`: The permission path that was checked (or `null` for callback checks) - `data`: Optional entity data passed to the check +- `reasons`: Denial strings from `{ allow: false, reason }` rules (empty for boolean denials) ## Advanced Usage diff --git a/docs/content/docs/integrations/nuxt.mdx b/docs/content/docs/integrations/nuxt.mdx new file mode 100644 index 00000000..d03ea2ca --- /dev/null +++ b/docs/content/docs/integrations/nuxt.mdx @@ -0,0 +1,280 @@ +--- +title: Nuxt +description: Learn how to use Permix with Nuxt +--- + +## Overview + +Permix provides a dedicated integration for [Nuxt](https://nuxt.com/) through `permix/nuxt`. It exposes a `createPermix` factory that returns a **per-request** Permix instance stored on the Nitro `event.context`, so you can `setup()` the rules once and `check()` them in server middleware, API routes, and Vue server components without leaking state between concurrent requests. + +The client side reuses the existing Vue integration (`permix/vue`): the server `dehydrate()`s its state and the client hydrates it into its own singleton via `PermixProvider` + `PermixHydrate`. + + + Before getting started with the Nuxt integration, make sure you've completed + the initial setup steps in the [Quick Start](/docs/quick-start) guide. + Familiarity with the [Hydration guide](/docs/guide/hydration) and the [Vue + integration](/docs/integrations/vue) helps too. + + + + + + +## Define your permissions + +Create a Permix instance once in a shared module so it can be imported from Nitro and from Vue: + +```ts title="lib/permix.ts" +import { createPermix } from 'permix/nuxt' + +interface Post { + id: string + authorId: string +} + +export const permix = createPermix<{ + post: [ + { name: 'create'; type: Post }, + { name: 'read'; type: Post }, + { name: 'update'; type: Post }, + { name: 'delete'; type: Post }, + ] +}>() +``` + +The returned helper does **not** hold any permission state at module scope — every request gets its own isolated instance on `event.context`. + + + + + +## Setup per request + +Call `setup()` early in the request lifecycle. A Nitro server middleware is a good place. Resolve any async data (session, headers, DB lookups) first, then pass plain rules to `setup`. Pass the `event` so the instance is stored on this request: + +```ts title="server/middleware/permix.ts" +import { permix } from '~/lib/permix' + +export default defineEventHandler((event) => { + const user = event.context.user + + permix.setup( + { + post: { + create: !!user, + read: true, + update: (post) => post?.authorId === user?.id, + delete: user?.role === 'admin', + }, + }, + event + ) +}) +``` + + + Because `setup()` is scoped to the current request's `event.context`, calling + it again from a nested route handler in the **same** request simply replaces + the rules for that request. Other requests are unaffected. + + +If you omit `event`, Permix tries h3's current-request helpers when they exist (`getRequestEvent` / `useEvent`). In Nitro handlers and Vue server components, pass `event` or `useRequestEvent()` so this works on all Nuxt versions. + + + + + +## Check on the server + +Anywhere a server route or Vue server component runs in that request, you can use `check()`. Pass `event` (or `useRequestEvent()`) when you are not relying on AsyncLocalStorage: + +```ts title="server/api/posts/index.post.ts" +import { permix } from '~/lib/permix' + +export default defineEventHandler((event) => { + if (!permix.get(event).check('post.create')) { + throw createError({ statusCode: 403, statusMessage: 'Forbidden' }) + } + + // create the post... + return { ok: true } +}) +``` + +```vue title="pages/posts/[id].vue" + +``` + +You can also reach the underlying core instance through `permix.get(event)` if you need methods like `isReady()` or `getRules()`. + + + + + +## Send permissions to the client + +Use `dehydrate()` to serialize the request's permissions and hand them to a client provider. The server cannot send the Permix instance itself across the boundary — only the JSON state. + +`permix/nuxt` is **server-only**. Create a client factory with `permix/vue` and wrap the app with the bound `PermixProvider` + `PermixHydrate`. Dehydrate in a server plugin so the client bundle never imports `h3`: + +```ts title="lib/permix-client.ts" +import { createPermix } from 'permix/vue' + +export const { permix, PermixProvider, PermixHydrate, usePermix, Check } = + createPermix<{ + post: [ + { name: 'create'; type: { id: string; authorId: string } }, + { name: 'read'; type: { id: string; authorId: string } }, + { name: 'update'; type: { id: string; authorId: string } }, + { name: 'delete'; type: { id: string; authorId: string } }, + ] + }>() +``` + +```ts title="plugins/permix.server.ts" +import { permix } from '~/lib/permix' + +export default defineNuxtPlugin(() => { + const event = useRequestEvent() + useState('permix-state', () => (event ? permix.dehydrate(event) : null)) +}) +``` + +```vue title="app.vue" + + + +``` + + + `hydrate()` restores the boolean state but does not flip `isReady` on its own + — function-based rules are lost during serialization. If you need `isReady` on + the client (e.g. to gate UI on `usePermix(...).isReady`), call + `permix.setup(...)` on the client too with the same shape (using booleans and + any function rules you want active client-side). See the [Hydration + guide](/docs/guide/hydration) for details. + + + + + + +## Use on the client + +From any Vue component, import the factory-bound `usePermix` from your `createPermix` module: + +```vue title="components/EditButton.vue" + + + +``` + +If you prefer the component API, use `Check` from `createPermix` in `permix/vue` — see the [Vue integration](/docs/integrations/vue). + + + + + +## Templates + +`createPermix` exposes the same `template()` helper as the core API for reusing rule sets: + +```ts title="lib/permix.ts" +import { createPermix } from 'permix/nuxt' + +export const permix = createPermix<{ + post: ['create', 'read', 'update', 'delete'] +}>() + +export const adminTemplate = permix.template({ + post: { create: true, read: true, update: true, delete: true }, +}) + +export const guestTemplate = permix.template({ + post: { create: false, read: true, update: false, delete: false }, +}) +``` + +```ts title="server/middleware/permix.ts" +import { permix, adminTemplate, guestTemplate } from '~/lib/permix' + +export default defineEventHandler((event) => { + const user = event.context.user + permix.setup( + user?.role === 'admin' ? adminTemplate() : guestTemplate(), + event + ) +}) +``` + + + + + +## Example + +You can find a runnable example of the Nuxt integration [here](https://github.com/letstri/permix/tree/main/examples/nuxt). + + + + + +## How per-request isolation works + +`createPermix` from `permix/nuxt` stores one core instance per factory on the current Nitro event's `context`. Inside one request: + +- The first call to `setup`/`check`/`get`/`dehydrate` creates (or reuses) **one** instance. +- All subsequent calls in the same request — across middleware, API routes, and Vue server components that share that event — share that instance. + +Across concurrent requests, each event object gets its **own** instance. State never leaks between users. + + + Do **not** store the result of `permix.get()` (or any rule data) in + module-level variables. That would defeat per-request isolation. Always go + through `permix.check()` / `permix.get(event)` so the request-scoped store is + consulted. + + +## API + +### `createPermix()` + +Returns an object with the following methods: + +| Method | Description | +| --- | --- | +| `setup(rules, event?)` | Set the per-request permission rules. Pass `event` in Nitro handlers (or `useRequestEvent()` in Vue). | +| `check(...args)` | Check a permission against the current request's rules (via `getRequestEvent()`). Same signature as the core `check`. | +| `get(event?)` | Return the underlying [`Permix`](/docs/guide/instance) instance for the request. | +| `getRules(event?)` | Return the current rules object for the request-scoped instance, or `null`. | +| `dehydrate(event?)` | Serialize the current request's rules to JSON (for `` on the client). | +| `template(rules)` | Create a reusable rule set. Same as the core [`template`](/docs/guide/template). | diff --git a/docs/content/docs/integrations/orpc.mdx b/docs/content/docs/integrations/orpc.mdx index 05329b58..b4ad980f 100644 --- a/docs/content/docs/integrations/orpc.mdx +++ b/docs/content/docs/integrations/orpc.mdx @@ -211,6 +211,7 @@ The `onForbidden` handler receives: - `path`: The permission path that was checked (e.g. `'post.create'`) - `data`: Optional data passed to the check +- `reasons`: Denial strings from `{ allow: false, reason }` rules (empty for boolean denials) - `context`: Your oRPC context object - `next`: The middleware `next` function — call it to allow the request through diff --git a/docs/content/docs/integrations/react-router.mdx b/docs/content/docs/integrations/react-router.mdx new file mode 100644 index 00000000..4bc4da26 --- /dev/null +++ b/docs/content/docs/integrations/react-router.mdx @@ -0,0 +1,255 @@ +--- +title: React Router +description: Learn how to use Permix with React Router 7 +--- + +## Overview + +Permix provides a dedicated integration for [React Router 7](https://reactrouter.com/) through `permix/react-router`. Remix apps that have moved to React Router 7 use this same adapter — there is no separate `permix/remix` export. + +It stores a **per-request** Permix instance on React Router's middleware context (`context.set` / `context.get`), so loaders, actions, and middleware share one instance. The client reuses [`permix/react`](/docs/integrations/react): dehydrate on the server and hydrate with `PermixProvider` + `PermixHydrate`. + + + Before getting started, complete the [Quick Start](/docs/quick-start). This + adapter uses [React Router + middleware](https://reactrouter.com/how-to/middleware) (React Router 7.9+). + Enable `v8_middleware` (or the current middleware flag) in your React Router + config if it is not on by default. + + + + + + +## Define your permissions + +```ts title="app/lib/permix.ts" +import type { ValidateDefinition } from 'permix' +import { createPermix } from 'permix/react-router' + +interface Post { + id: string + authorId: string +} + +export type PermissionsDefinition = ValidateDefinition<{ + post: [ + { name: 'create'; type: Post }, + { name: 'read'; type: Post }, + { name: 'update'; type: Post }, + { name: 'delete'; type: Post }, + ] +}> + +export const permix = createPermix() +``` + +Each `createPermix()` call uses its own context key, so two factories on the same request do not collide. + + + + + +## Setup per request + +Register `setupMiddleware` on the root route so every request gets a fresh instance: + +```ts title="app/root.tsx" +import { permix } from './lib/permix' +import { getSession } from './lib/auth' + +export const middleware = [ + permix.setupMiddleware(async ({ request }) => { + const session = await getSession(request) + + return { + post: { + create: !!session, + read: true, + update: (post) => post?.authorId === session?.userId, + delete: session?.role === 'admin', + }, + } + }), +] +``` + +You can also attach it to a specific route's `middleware` array instead of the root. + + + + + +## Check in loaders and actions + +```ts title="app/routes/posts.$id.tsx" +import { data } from 'react-router' +import { permix } from '../lib/permix' +import { getPost } from '../lib/posts' +import type { Route } from './+types/posts.$id' + +export async function loader({ params, context }: Route.LoaderArgs) { + const post = await getPost(params.id) + + if (!permix.getOrThrow(context).check('post.read', post)) { + throw data('Not Found', { status: 404 }) + } + + return { post, permixState: permix.dehydrate(context) } +} + +export async function action({ context }: Route.ActionArgs) { + if (!permix.getOrThrow(context).check('post.create')) { + throw data({ error: 'Forbidden' }, { status: 403 }) + } + + return { ok: true } +} +``` + +To guard a whole route, compose `checkMiddleware` after setup: + +```ts title="app/routes/posts.new.tsx" +import { permix } from '../lib/permix' + +export const middleware = [permix.checkMiddleware('post.create')] +``` + +Denied requests default to `403` with `{ error: 'Forbidden' }`. Customize with `onForbidden` (for example to `redirect`). + + + + + +## Hydrate the client + +Dehydrate in a loader (often the root loader) and wrap the tree with `permix/react`: + +```tsx title="app/root.tsx" +import { PermixHydrate, PermixProvider } from 'permix/react' +import { createPermix } from 'permix' +import { permix as serverPermix } from './lib/permix' +import type { PermissionsDefinition } from './lib/permix' +import type { Route } from './+types/root' + +const clientPermix = createPermix() + +export async function loader({ context }: Route.LoaderArgs) { + return { permixState: serverPermix.dehydrate(context) } +} + +export function Layout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +export default function App({ loaderData }: Route.ComponentProps) { + return ( + + + + + + ) +} +``` + + + `hydrate()` restores booleans but does not restore function-based rules or + mark the instance ready. Call `clientPermix.setup(...)` on the client with the + full rule set after hydration. See the [Hydration + guide](/docs/guide/hydration). + + + + + + +## Use on the client + +```tsx title="app/components/edit-button.tsx" +import { usePermix } from 'permix/react' + +export function EditButton({ + permix, + post, +}: { + permix: Parameters[0] + post: { id: string; authorId: string } +}) { + const { check } = usePermix(permix) + + if (!check('post.update', post)) { + return null + } + + return +} +``` + +Pass the same client factory you used for `PermixProvider`. See the [React integration](/docs/integrations/react). + + + + + +## Templates + +```ts title="app/lib/permix.ts" +import { createPermix } from 'permix/react-router' + +export const permix = createPermix<{ + post: ['create', 'read', 'update', 'delete'] +}>() + +export const adminTemplate = permix.template({ + post: { create: true, read: true, update: true, delete: true }, +}) + +export const guestTemplate = permix.template({ + post: { create: false, read: true, update: false, delete: false }, +}) +``` + +```ts title="app/root.tsx" +export const middleware = [ + permix.setupMiddleware(async ({ request }) => { + const session = await getSession(request) + return session?.role === 'admin' ? adminTemplate() : guestTemplate() + }), +] +``` + + + + + +## Example + +You can find a runnable example of the React Router integration [here](https://github.com/letstri/permix/tree/main/examples/react-router). + + + + + +## Remix + +Remix merged into React Router 7. Use `permix/react-router` — do not look for a `permix/remix` export. + +## API + +### `createPermix(options?)` + +| Method | Description | +| --- | --- | +| `setupMiddleware(rules \| callback)` | Middleware that creates a per-request instance on `context`. | +| `checkMiddleware(...args)` | Middleware that allows or returns the `onForbidden` response. | +| `get(context)` | Return the instance, or `null`. | +| `getOrThrow(context)` | Return the instance, or throw `PermixNotFoundError`. | +| `dehydrate(context)` | Serialize rules for ``. | +| `getRules(context)` | Return the current rules, or `null`. | +| `template(rules)` | Create a reusable rule set. | +| `context` | The opaque key passed to `context.set` / `context.get`. | diff --git a/docs/content/docs/integrations/react.mdx b/docs/content/docs/integrations/react.mdx index 2eb3e010..f2e6ad76 100644 --- a/docs/content/docs/integrations/react.mdx +++ b/docs/content/docs/integrations/react.mdx @@ -5,7 +5,9 @@ description: Learn how to use Permix with React applications ## Overview -Permix provides official React integration through the `PermixProvider` component and `usePermix` hook. This allows you to manage permissions reactively in your React app. +Permix provides official React integration through `createPermix` from `permix/react`. Same factory name as `permix/next` and `permix/express`: it returns a Permix instance plus isolated `PermixProvider`, `usePermix`, `Check`, and `PermixHydrate` bindings. + +The adapter supports React 18 and React 19. On React 19.2+ it uses native `useEffectEvent`; React 18 gets a compatible fallback. Peers are `react` / `react-dom` `>=18`. Before getting started with React integration, make sure you've completed the @@ -18,25 +20,42 @@ Permix provides official React integration through the `PermixProvider` componen ## Setup -First, wrap your application with the `PermixProvider`: +Call `createPermix` once at module scope: + +```ts title="lib/permix.ts" +import { createPermix } from 'permix/react' + +export const { permix, PermixProvider, PermixHydrate, usePermix, Check } = + createPermix<{ + post: ['create', 'read', { name: 'edit'; type: Post }] + }>() +``` + +Pass an existing core instance when you already created one (for example a client copy used with server hydration): + +```ts +import { createPermix } from 'permix/react' +import { clientPermix } from './client-core' + +export const { PermixProvider, PermixHydrate, usePermix, Check } = + createPermix(clientPermix) +``` + +Wrap the tree with the bound provider. It does not take a `permix` prop — the factory already closed over the instance: ```tsx title="App.tsx" -import { PermixProvider } from 'permix/react' -import { permix } from './lib/permix' +import { PermixProvider } from './lib/permix' function App() { return ( - + ) } ``` - - Remember to always pass the same Permix instance to both the `PermixProvider` - and `usePermix` hook to maintain type safety. - +Each `createPermix` call gets its own React context, so nested factories (for example a posts policy and a comments policy) stay independent. @@ -44,14 +63,29 @@ function App() { ## Hook -For checking permissions in your components, you can use the `usePermix` hook. And to avoid importing the hook and Permix instance in every component, you can create a custom hook: +`usePermix()` from the factory does not take an instance argument: + +```tsx title="page.tsx" +import { usePermix } from './lib/permix' + +export default function Page() { + const post = usePost() + const { check, explain, isReady } = usePermix() -```tsx title="hooks/use-permissions.ts" -import { usePermix } from 'permix/react' -import { permix } from '../lib/permix' + if (!isReady) { + return
Loading permissions...
+ } -export function usePermissions() { - return usePermix(permix) + const canEdit = check('post.edit', post) + const denial = explain('post.edit', post) + + return canEdit ? ( + + ) : ( + + ) } ``` @@ -61,19 +95,11 @@ export function usePermissions() { ## Components -If you prefer using components, you can import the `createComponents` function from `permix/react` and create checking components: - -```ts title="lib/permix.ts" -import { createComponents } from 'permix/react' - -// ... - -export const { Check } = createComponents(permix) -``` - -And then you can use the `Check` component in your components: +The factory also returns a typed `Check` component: ```tsx title="page.tsx" +import { Check } from './lib/permix' + export default function Page() { return ( -## Usage - -Use the `usePermix` hook and checking components in your components: - -```tsx title="page.tsx" -import { usePermix } from 'permix/react' -import { permix } from './lib/permix' -import { Check } from './lib/permix-components' - -export default function Page() { - const post = usePost() - const { check, isReady } = usePermix(permix) - - if (!isReady) { - return
Loading permissions...
- } - - const canEdit = check('post.edit', post) - - return ( -
- {canEdit ? ( - - ) : ( -

You don't have permission to edit this post

- )} - - Can I create a post inside the Check component? - -
- ) -} -``` - - - - - ## Hydration -For SSR applications, use `PermixHydrate` to restore dehydrated server state on the client. `hydrate()` does not mark the instance ready and cannot restore function-based rules — call `setup()` on the client with the full rule set (usually in the same place you restore the session): +For SSR, use the bound `PermixHydrate` to restore dehydrated server state on the client. `hydrate()` does not mark the instance ready and cannot restore function-based rules — call `install({ rules })` (or `setup()`) on the client with the full rule set: ```tsx title="App.tsx" import { useEffect } from 'react' import type { DehydratedState } from 'permix' -import { PermixHydrate, PermixProvider } from 'permix/react' -import { permix } from './lib/permix' +import { permix, PermixHydrate, PermixProvider } from './lib/permix' import { getClientRules } from './lib/permissions' -function App({ dehydratedState }: { dehydratedState: DehydratedState }) { +function App({ + dehydratedState, +}: { + dehydratedState: DehydratedState<{ post: ['create', 'read'] }> +}) { useEffect(() => { - permix.setup(getClientRules()) + permix.install({ rules: getClientRules() }) }, []) return ( - + @@ -156,7 +147,7 @@ function App({ dehydratedState }: { dehydratedState: DehydratedState }) { } ``` -See the [Hydration guide](/docs/guide/hydration) and framework-specific pages for [Next.js](/docs/integrations/next) and [TanStack Start](/docs/integrations/tanstack-start). +See the [Hydration guide](/docs/guide/hydration) and framework-specific pages for [Next.js](/docs/integrations/next), [TanStack Start](/docs/integrations/tanstack-start), and [React Router](/docs/integrations/react-router). `PermixHydrate` uses a dehydrated snapshot on the first render so boolean checks work before client `setup()`. `isReady` stays `false` until you call `setup()` or `install({ rules })` on the client. diff --git a/docs/content/docs/integrations/server.mdx b/docs/content/docs/integrations/server.mdx index 48d2f32e..91347852 100644 --- a/docs/content/docs/integrations/server.mdx +++ b/docs/content/docs/integrations/server.mdx @@ -219,6 +219,7 @@ The `onForbidden` handler receives: - `next`: the downstream handler — call it to let the request through anyway - `path`: the permission path that was checked (e.g. `'post.create'`) - `data`: optional data passed to the check +- `reasons`: denial strings from `{ allow: false, reason }` rules (empty for boolean denials) ## Advanced Usage diff --git a/docs/content/docs/integrations/solid.mdx b/docs/content/docs/integrations/solid.mdx index c02e5393..73ffd0b2 100644 --- a/docs/content/docs/integrations/solid.mdx +++ b/docs/content/docs/integrations/solid.mdx @@ -5,7 +5,7 @@ description: Learn how to use Permix with Solid applications ## Overview -Permix provides official Solid integration through the `PermixProvider` component and `usePermix` hook. This allows you to manage permissions reactively in your Solid app. +Permix provides official Solid integration through `createPermix` from `permix/solid`. Same factory name as `permix/react` and `permix/express`: it returns a Permix instance plus isolated `PermixProvider`, `usePermix`, `Check`, and `PermixHydrate` bindings. Before getting started with Solid integration, make sure you've completed the @@ -18,112 +18,57 @@ Permix provides official Solid integration through the `PermixProvider` componen ## Setup -First, wrap your application with the `PermixProvider`: +Call `createPermix` once at module scope: -```tsx title="App.tsx" -import { PermixProvider } from 'permix/solid' -import { permix } from './lib/permix' +```ts title="lib/permix.ts" +import { createPermix } from 'permix/solid' -function App() { - return ( - - - - ) -} +export const { permix, PermixProvider, PermixHydrate, usePermix, Check } = + createPermix<{ + post: ['create', 'read', { name: 'edit'; type: Post }] + }>() ``` - - Remember to always pass the same Permix instance to both the `PermixProvider` - and `usePermix` hook to maintain type safety. - +Wrap the tree with the bound provider. It does not take a `permix` prop: - +```tsx title="index.tsx" +import { PermixProvider } from './lib/permix' - - -## Hook - -For checking permissions in your components, you can use the `usePermix` hook. And to avoid importing the hook and Permix instance in every component, you can create a custom utility: - -```tsx title="hooks/use-permissions.ts" -import { usePermix } from 'permix/solid' -import { permix } from '../lib/permix' - -export function usePermissions() { - return usePermix(permix) -} -``` - - - - - -## Components - -If you prefer using components, you can import the `createComponents` function from `permix/solid` and create checking components: - -```ts title="lib/permix.ts" -import { createComponents } from 'permix/solid' - -// ... - -export const { Check } = createComponents(permix) +render( + () => ( + + + + ), + root +) ``` -And then you can use the `Check` component in your components: - -```tsx title="page.tsx" -export default function Page() { - return ( - Will show this if a user doesn't have permission

} - reverse - > - Will show this if a user has permission -
- ) -} -``` +Each `createPermix` call gets its own Solid context, so nested factories stay independent.
-## Usage +## Hook -Use the `usePermix` hook and checking components in your components: +`usePermix()` from the factory does not take an instance argument. `isReady` is a function: ```tsx title="page.tsx" -import { usePermix } from 'permix/solid' -import { permix } from './lib/permix' -import { Check } from './lib/permix-components' +import { usePermix, Check } from './lib/permix' export default function Page() { const post = usePost() - const { check, isReady } = usePermix(permix) + const { check, explain, isReady } = usePermix() - const canEdit = () => check('post.edit', post) + if (!isReady()) { + return
Loading permissions...
+ } - return ( - <> - {!isReady() ? ( -
Loading permissions...
- ) : ( -
- {canEdit() ? ( - - ) : ( -

You don't have permission to edit this post

- )} - - Can I create a post inside the Check component? - -
- )} - + return check('post.edit', post) ? ( + + ) : ( + Can I create a post? ) } ``` @@ -134,25 +79,20 @@ export default function Page() { ## Hydration -For SSR, use `PermixHydrate` with the dehydrated server state, then call `permix.setup()` on the client to restore function-based rules: +For SSR, use the bound `PermixHydrate` for first-paint booleans, then `install({ rules })` on the client: ```tsx title="App.tsx" import { onMount } from 'solid-js' -import type { DehydratedState } from 'permix' -import { PermixHydrate, PermixProvider } from 'permix/solid' -import { permix } from './lib/permix' +import { permix, PermixHydrate, PermixProvider } from './lib/permix' import { getClientRules } from './lib/permissions' -export function App(props: { - dehydratedState: DehydratedState - children: any -}) { +export function App(props: { dehydratedState: unknown; children: any }) { onMount(() => { - permix.setup(getClientRules()) + permix.install({ rules: getClientRules() }) }) return ( - + {props.children} diff --git a/docs/content/docs/integrations/standard-schema.mdx b/docs/content/docs/integrations/standard-schema.mdx new file mode 100644 index 00000000..7de056ed --- /dev/null +++ b/docs/content/docs/integrations/standard-schema.mdx @@ -0,0 +1,250 @@ +--- +title: Standard Schema +description: Infer entity types from Zod, Valibot, ArkType, and other Standard Schema validators +--- + +## Overview + +Permix can take entity types from any library that implements [Standard Schema](https://standardschema.dev/) — Zod, Valibot, ArkType, Effect Schema, and others. You already have those schemas for API validation; reuse them so permission callbacks and `check()` stay aligned with the same object shape. + +Two entry points: + +| Import | Use when | +| --- | --- | +| `action` from `permix` | You already have a `createPermix()` tree and want a few actions typed from a schema | +| `createPermix` from `permix/standard-schema` | You want a Drizzle-style factory: one entity per schema, CRUD actions by default | + +Type inference is always on. Runtime parsing is **off** unless you pass `{ validate: 'deny' | 'throw' }` to the factory. Core `createPermix()` never parses `check()` data. + + + Before getting started, complete the initial setup steps in the [Quick + Start](/docs/quick-start) guide. + + + + + + +## `schema` on an action spec + +Pass `schema: typeof yourSchema` instead of (or in addition to) `type`. Entity data is inferred from the schema's output type. If both `type` and `schema` are set, **`type` wins**. + +```ts twoslash title="/lib/permix-schema.ts" +import { createPermix } from 'permix' +import { z } from 'zod' + +const postSchema = z.object({ + id: z.string(), + authorId: z.string(), +}) + +const permix = createPermix<{ + post: ['create', { name: 'edit'; schema: typeof postSchema; required: true }] +}>() + +permix.setup({ + post: { + create: true, + edit: (post) => post.authorId === 'user-1', + // ^? + }, +}) + +permix.check('post.edit', { id: '1', authorId: 'user-1' }) +``` + +The same `schema` field works with Valibot, ArkType, or any other Standard Schema implementation — not only Zod. + + + + + +## `action()` helper + +`action(name, schema, { required }?)` builds an action spec so you can infer the definition from a value instead of repeating `typeof schema` on every leaf. + +```ts twoslash title="/lib/permix-action.ts" +import { action, createPermix } from 'permix' +import { z } from 'zod' + +const postSchema = z.object({ + id: z.string(), + authorId: z.string(), +}) + +const definition = { + post: ['create', action('edit', postSchema, { required: true })], +} as const + +const permix = createPermix() + +permix.setup({ + post: { + create: true, + edit: (post) => post.authorId === 'user-1', + }, +}) + +permix.check('post.edit', { id: '1', authorId: 'user-1' }) +``` + +Use `as const` so action names stay literal. Nested trees work the same way — `action()` is only a leaf constructor. + + + + + +## Schema map factory + +`permix/standard-schema` turns a map of schemas into a Permix instance. Each key becomes an entity; by default each entity gets `create`, `read`, `update`, and `delete`, with entity data typed from that schema. + +```ts twoslash title="/lib/standard-schema.ts" +import { createPermix } from 'permix/standard-schema' +import { z } from 'zod' + +const postSchema = z.object({ + id: z.string(), + authorId: z.string(), +}) + +const commentSchema = z.object({ + id: z.string(), + postId: z.string(), +}) + +const permix = createPermix({ + post: postSchema, + comment: commentSchema, +}) + +permix.setup({ + post: { + create: true, + read: true, + update: (post) => post.authorId === 'user-1', + delete: false, + }, + comment: { create: true, read: true, update: false, delete: false }, +}) + +permix.check('post.update', { id: '1', authorId: 'user-1' }) +permix.check('comment.delete') +``` + +The returned instance is a regular Permix object — `check`, `setup`, `template`, `dehydrate`, `hydrate`, `hook`, `isReady` work unchanged. + + + + + +## Customising actions + +Override the action list for every bare schema, or per entity with `entity()`. Plain action tuples stay untyped (same as `createPermix<{ dashboard: ['view'] }>()`). + +```ts twoslash title="/lib/standard-schema-entity.ts" +import { createPermix, entity } from 'permix/standard-schema' +import { z } from 'zod' + +const postSchema = z.object({ + id: z.string(), + authorId: z.string(), +}) + +const permix = createPermix({ + post: entity(postSchema, [ + 'create', + 'read', + { name: 'publish', required: true }, + ]), + dashboard: ['view'], +}) + +permix.setup({ + post: { + create: true, + read: true, + publish: (post) => post.authorId === 'user-1', + }, + dashboard: { view: true }, +}) + +permix.check('post.publish', { id: '1', authorId: 'user-1' }) +permix.check('dashboard.view') +``` + +Same action names for every schema, without `entity()`: + +```ts +const permix = createPermix( + { post: postSchema, comment: commentSchema }, + { actions: ['view', 'edit'] as const } +) +``` + + + + + +## Discovering entities and actions at runtime + +```ts +const permix = createPermix({ post: postSchema }) + +permix.entities // ['post'] +permix.actions // readonly ['create', 'read', 'update', 'delete'] +permix.validate // false | 'deny' | 'throw' +``` + +`actions` is the default set applied to **bare** schemas. `entity()` entries keep their own list. + + + + + +## Runtime validation + +By default the factory still only infers types. Pass `validate` to parse `check()` data with the entity schema **before** the rule runs. The rule sees the parsed output, including transforms. + +| Mode | Invalid data | +| --- | --- | +| omitted / `false` | No parse. The rule receives the raw argument. | +| `'deny'` | `check()` returns `false` | +| `'throw'` | `check()` throws `PermixValidationError` (`error.path`, `error.issues`) | + +Checks without data, `'~any'` / `'~all'`, and untyped action tuples skip validation. Async schemas throw `PermixAsyncValidationError` — `check()` is synchronous. + +```ts twoslash title="/lib/standard-schema-validate.ts" +import { createPermix } from 'permix/standard-schema' +import { z } from 'zod' + +const postSchema = z.object({ + id: z.string(), + authorId: z.string(), +}) + +const permix = createPermix({ post: postSchema }, { validate: 'deny' }) + +permix.setup({ + post: { + create: true, + read: true, + update: (post) => post?.authorId === 'user-1', + delete: false, + }, +}) + +permix.check('post.update', { id: '1', authorId: 'user-1' }) +``` + +Prefer `'deny'` in UI `check()` paths. Use `'throw'` on the server when invalid entity payloads should be treated as a programmer error. tRPC/oRPC input schemas still belong on the procedure — this is a backstop for `check()` data, not a replacement for request validation. + + + + + +## Notes + +- Nested permission trees (`workspace.member`) stay on core `createPermix()` plus `action()` — the factory is one entity level, like [Drizzle](/docs/integrations/drizzle). +- Core `createPermix()` never parses. The factory parses only when `validate` is `'deny'` or `'throw'`. +- No extra peer dependency. Install Zod (or Valibot, ArkType, …) yourself. Permix only depends on the Standard Schema type protocol. +- Complements Drizzle: Drizzle answers “which tables and actions?”; Standard Schema answers “what does the entity look like?” diff --git a/docs/content/docs/integrations/svelte.mdx b/docs/content/docs/integrations/svelte.mdx index 9612c18f..1b04e860 100644 --- a/docs/content/docs/integrations/svelte.mdx +++ b/docs/content/docs/integrations/svelte.mdx @@ -5,12 +5,11 @@ description: Learn how to use Permix with Svelte applications ## Overview -Permix provides official Svelte integration through the `PermixProvider` component and `usePermix` hook. This allows you to manage permissions reactively in your Svelte app using runes. +Permix provides official Svelte integration through `createPermix` from `permix/svelte`. Same factory name as `permix/react` and `permix/express`: it returns a Permix instance plus isolated `PermixProvider`, `usePermix`, `Check`, and `PermixHydrate` bindings. Requires Svelte 5. Before getting started with Svelte integration, make sure you've completed the - initial setup steps in the [Quick Start](/docs/quick-start) guide. Svelte - integration requires Svelte 5. + initial setup steps in the [Quick Start](/docs/quick-start) guide. @@ -19,25 +18,32 @@ Permix provides official Svelte integration through the `PermixProvider` compone ## Setup -First, wrap your application with the `PermixProvider`: +Call `createPermix` once at module scope: -```svelte title="App.svelte" +```ts title="lib/permix.ts" +import { createPermix } from 'permix/svelte' + +export const { permix, PermixProvider, PermixHydrate, usePermix, Check } = + createPermix<{ + post: ['create', 'read', { name: 'edit'; type: Post }] + }>() +``` + +Wrap the tree with the bound provider. It does not take a `permix` prop: + +```svelte title="+layout.svelte" - + {@render children()} ``` - - Remember to always pass the same Permix instance to both the `PermixProvider` - and `usePermix` hook to maintain type safety. - +Each `createPermix` call gets its own context key, so nested factories stay independent.
@@ -45,91 +51,26 @@ First, wrap your application with the `PermixProvider`: ## Hook -For checking permissions in your components, you can use the `usePermix` hook. And to avoid importing the hook and Permix instance in every component, you can create a custom utility: - -```ts title="lib/use-permissions.ts" -import { usePermix } from 'permix/svelte' -import { permix } from './permix' - -export function usePermissions() { - return usePermix(permix) -} -``` - - - - - -## Components - -If you prefer using components, you can import the `createComponents` function from `permix/svelte` and create checking components: - -```ts title="lib/permix.ts" -import { createComponents } from 'permix/svelte' - -// ... - -export const { Check } = createComponents(permix) -``` - -And then you can use the `Check` component in your components. The default content is rendered when a user has permission, and the `otherwise` snippet is rendered when they don't: - -```svelte title="Page.svelte" - - - - - Will show this if a user has permission - {#snippet otherwise()} - Will show this if a user doesn't have permission - {/snippet} - -``` - - - - - -## Usage - -Use the `usePermix` hook and checking components in your components: +`usePermix()` from the factory does not take an instance argument. `isReady` is a reactive getter — access it on the returned object (don't destructure): ```svelte title="Page.svelte" -
- {#if !permissions.isReady} -
Loading permissions...
- {:else if permissions.check('post.edit', post)} - - {:else} -

You don't have permission to edit this post

- {/if} - - - Can I create a post inside the Check component? - -
-``` +{#if !permissions.isReady} +
Loading permissions...
+{:else if permissions.check('post.edit', post)} + +{/if} - - `usePermix` returns an object with a reactive `isReady` getter and a `check` - method. Access them directly on the returned object (for example - `permissions.isReady`) to keep reactivity — don't destructure `isReady`. - + + Can I create a post inside the Check component? + +```
@@ -137,27 +78,22 @@ Use the `usePermix` hook and checking components in your components: ## Hydration -For SSR, wrap the app with `PermixHydrate` and pass the dehydrated server state. Call `permix.setup()` on the client afterward to restore function-based rules and set `isReady`: +For SSR, use the bound `PermixHydrate` for first-paint booleans, then `install({ rules })` on the client: -```svelte title="App.svelte" +```svelte title="+layout.svelte" - + {@render children()} diff --git a/docs/content/docs/integrations/tanstack-start.mdx b/docs/content/docs/integrations/tanstack-start.mdx index fcf485f0..73961e09 100644 --- a/docs/content/docs/integrations/tanstack-start.mdx +++ b/docs/content/docs/integrations/tanstack-start.mdx @@ -409,7 +409,7 @@ export function EditButton({ } ``` -If you prefer the component API, create checkers with `createComponents` from `permix/react` — see the [React integration](/docs/integrations/react#components) for details. +If you prefer the component API, use `Check` from `createPermix` in `permix/react` — see the [React integration](/docs/integrations/react). @@ -493,7 +493,7 @@ Returns an object with the following methods: | Option | Description | | --- | --- | -| `onForbidden({ path, data })` | Called when `checkMiddleware` denies a request. Defaults to throwing a `PermixError`. | +| `onForbidden({ path, data, reasons })` | Called when `checkMiddleware` denies a request. Defaults to throwing a `PermixForbiddenError`. | ### Hooks diff --git a/docs/content/docs/integrations/trpc.mdx b/docs/content/docs/integrations/trpc.mdx index 0836718c..4d290258 100644 --- a/docs/content/docs/integrations/trpc.mdx +++ b/docs/content/docs/integrations/trpc.mdx @@ -209,6 +209,7 @@ The `onForbidden` handler receives: - `path`: The permission path that was checked (e.g. `'post.create'`) - `data`: Optional data passed to the check +- `reasons`: Denial strings from `{ allow: false, reason }` rules (empty for boolean denials) - `ctx`: Your tRPC context object - `next`: The middleware `next` function — call it to allow the request through diff --git a/docs/content/docs/integrations/vue.mdx b/docs/content/docs/integrations/vue.mdx index fb47ab9a..b09ef424 100644 --- a/docs/content/docs/integrations/vue.mdx +++ b/docs/content/docs/integrations/vue.mdx @@ -5,7 +5,7 @@ description: Learn how to use Permix with Vue applications ## Overview -Permix provides official Vue integration through the `PermixProvider` component and `usePermix` composable. This allows you to manage permissions reactively in your Vue app. +Permix provides official Vue integration through `createPermix` from `permix/vue`. Same factory name as `permix/react` and `permix/express`: it returns a Permix instance plus isolated `PermixProvider`, `usePermix`, `Check`, and `PermixHydrate` bindings. Before getting started with Vue integration, make sure you've completed the @@ -18,43 +18,31 @@ Permix provides official Vue integration through the `PermixProvider` component ## Setup -First, wrap your application with the `PermixProvider`: +Call `createPermix` once at module scope: -```vue title="App.vue" - +```ts title="lib/permix.ts" +import { createPermix } from 'permix/vue' - +export const { permix, PermixProvider, PermixHydrate, usePermix, Check } = + createPermix<{ + post: ['create', 'read', { name: 'edit'; type: Post }] + }>() ``` -You can also register the provider from `main.ts`: +Wrap the tree with the bound provider. It does not take a `permix` prop: ```ts title="main.ts" import { createApp } from 'vue' -import { PermixProvider } from 'permix/vue' -import { permix } from './lib/permix' import App from './App.vue' +import { PermixProvider } from './lib/permix' createApp({ components: { PermixProvider, App }, - template: '', - setup() { - return { permix } - }, + template: '', }).mount('#app') ``` - - Remember to always pass the same Permix instance to both the `PermixProvider` - and `usePermix` composable to maintain type safety. - +Each `createPermix` call gets its own Vue inject key, so nested factories stay independent. @@ -62,84 +50,22 @@ createApp({ ## Composable -For checking permissions in your components, you can use the `usePermix` composable. And to avoid importing the composable and Permix instance in every component, you can create a custom composable: - -```ts title="composables/use-permissions.ts" -import { usePermix } from 'permix/vue' -import { permix } from './lib/permix' - -export function usePermissions() { - return usePermix(permix) -} -``` - - - - - -## Components - -If you prefer using components, you can import the `createComponents` function from `permix/vue` and create checking components: - -```ts title="lib/permix.ts" -import { createComponents } from 'permix/vue' - -// ... - -export const { Check } = createComponents(permix) -``` - -And then you can use the `Check` component in your templates: - -```vue title="page.vue" - -``` - - - - - -## Usage - -Use the `usePermix` composable in your components to check permissions: +`usePermix()` from the factory does not take an instance argument: ```vue title="page.vue" ``` @@ -149,35 +75,31 @@ const { check, isReady } = usePermix(permix) ## Hydration -For SSR applications, use `PermixHydrate` to restore dehydrated server state on the client. `hydrate()` does not mark the instance ready and cannot restore function-based rules — call `setup()` on the client with the full rule set (usually in the same place you restore the session): +For SSR, use the bound `PermixHydrate` for first-paint booleans, then `install({ rules })` (or `setup()`) on the client to attach function rules: ```vue title="App.vue" ``` -See the [Hydration guide](/docs/guide/hydration). +See the [Hydration guide](/docs/guide/hydration). `isReady` stays `false` until you call `setup()` / `install({ rules })`. diff --git a/docs/content/docs/meta.json b/docs/content/docs/meta.json index dbd79bfc..4da9c3c3 100644 --- a/docs/content/docs/meta.json +++ b/docs/content/docs/meta.json @@ -4,10 +4,13 @@ "index", "quick-start", "migration-v3-to-v4", + "migration-v4-to-v5", "comparison", + "changelog", "---Guide---", "guide/instance", "guide/setup", + "guide/extraction", "guide/check", "guide/template", "guide/rebac", @@ -18,19 +21,24 @@ "integrations/react", "integrations/next", "integrations/tanstack-start", + "integrations/nuxt", + "integrations/react-router", "integrations/vue", "integrations/solid", "integrations/svelte", "integrations/node", "integrations/server", + "integrations/astro", "integrations/trpc", "integrations/orpc", "integrations/express", "integrations/hono", "integrations/elysia", "integrations/fastify", + "integrations/nest", "integrations/effect", "integrations/drizzle", + "integrations/standard-schema", "---", "[Examples](https://github.com/letstri/permix/tree/main/examples)", "---LLMs---", diff --git a/docs/content/docs/migration-v3-to-v4.mdx b/docs/content/docs/migration-v3-to-v4.mdx index 20be84ca..dd21e8ac 100644 --- a/docs/content/docs/migration-v3-to-v4.mdx +++ b/docs/content/docs/migration-v3-to-v4.mdx @@ -16,7 +16,7 @@ This guide summarizes the breaking changes and how to update your app. For the f permix@^4 ``` -v4 is developed with **TypeScript 6**. Your app does not need to match the monorepo's exact TypeScript or pnpm versions, but upgrade TypeScript if you hit inference issues. +v4 is developed with **TypeScript 7** and supports **TypeScript 5.9–7**. Your app does not need to match the monorepo's exact TypeScript or pnpm versions, but upgrade TypeScript if you hit inference issues. ## Quick reference @@ -240,7 +240,7 @@ permix.setup(getClientRules(user)) `hydrate()` fires the **`setup` hook** (not a separate `hydrate` hook). Update listeners that used `hook('hydrate', ...)` in v3. -See [Hydration](/docs/guide/hydration) and [Ready state](/docs/guide/ready). For App Router / TanStack Start, see [Next.js](/docs/integrations/next) and [TanStack Start](/docs/integrations/tanstack-start). +See [Hydration](/docs/guide/hydration) and [Ready state](/docs/guide/ready). For App Router / TanStack Start / React Router, see [Next.js](/docs/integrations/next), [TanStack Start](/docs/integrations/tanstack-start), and [React Router](/docs/integrations/react-router). --- @@ -336,15 +336,18 @@ Map each Better Auth role to a rules object yourself — the same booleans you p These are additive — migrate the core API first, then adopt what you need: -| Import | Use case | -| ----------------------- | ------------------------------------------- | -| `permix/next` | Next.js App Router, request-scoped instance | -| `permix/tanstack-start` | TanStack Start middleware and SSR | -| `permix/server` | Framework-agnostic fetch middleware | -| `permix/svelte` | Svelte 5 runes | -| `permix/drizzle` | Rules from Drizzle v1 schema | -| `permix/drizzle/legacy` | Drizzle v0 (`>=0.30 <1`) | -| `permix/effect` | Effect `Layer` / `Context` | +| Import | Use case | +| ------------------------ | ---------------------------------------------- | +| `permix/next` | Next.js App Router, resolver + dual RSC access | +| `permix/tanstack-start` | TanStack Start middleware and SSR | +| `permix/nuxt` | Nuxt / Nitro, request-scoped instance | +| `permix/react-router` | React Router 7 middleware and SSR | +| `permix/server` | Framework-agnostic fetch middleware | +| `permix/astro` | Astro middleware and `locals` | +| `permix/svelte` | Svelte 5 runes | +| `permix/drizzle` | Rules from Drizzle v1 schema | +| `permix/standard-schema` | Entity types from Zod, Valibot, ArkType, … | +| `permix/effect` | Effect `Layer` / `Context` | See the [examples directory](https://github.com/letstri/permix/tree/main/examples) (`next`, `tanstack-start`, `svelte`, `rebac`, and updated `react`, `vue`, …). diff --git a/docs/content/docs/migration-v4-to-v5.mdx b/docs/content/docs/migration-v4-to-v5.mdx new file mode 100644 index 00000000..5c4edcec --- /dev/null +++ b/docs/content/docs/migration-v4-to-v5.mdx @@ -0,0 +1,64 @@ +--- +title: Migrate v4 to v5 +description: Breaking change — setup() and hydrate() return frozen instances +icon: RiArrowUpCircleLine +--- + +## Overview + +Permix **v5** makes authorization instances immutable. `setup()` and `hydrate()` **return** a new frozen instance. They never mutate the factory. + +v4's in-place `setup()` is gone. There is no mutating alias. + +## Install + +```package-install +permix@^5 +``` + +## Quick reference + +| v4 | v5 | +| --- | --- | +| `const p = createPermix(); p.setup(rules); p.check(...)` | `const p = createPermix().setup(rules); p.check(...)` | +| `p.setup(next)` mutates `p` | `const next = p.setup(rules)` — `p` is unchanged | +| `p.hydrate(state)` mutates `p` | `const hydrated = p.hydrate(state)` | +| Overlapping `setup()` last-write-wins | Each returned instance is isolated | +| Next `cache()`: create then `setup()` then return | `return createPermix().setup(await rules)` | + +## Capture the returned instance + +```ts +// v4 +const permix = createPermix<{ post: ['create'] }>() +permix.setup({ post: { create: true } }) +permix.check('post.create') + +// v5 +const permix = createPermix<{ post: ['create'] }>().setup({ + post: { create: true }, +}) +permix.check('post.create') +``` + +On the server, keep using adapter `setupMiddleware` / `setupContext`. Those already stash the instance returned from `setup()`. + +Do not treat a module-level `createPermix()` factory as a request-scoped singleton you `setup()` in place. + +## Hydration + +```ts +const dehydrated = server.dehydrate() +const client = createPermix().hydrate(dehydrated) // isReady() === false +const ready = client.install({ rules: clientRules }) // isReady() === true +``` + +`hydrate()` does **not** fire the `setup` hook. UI providers subscribe to `setup` / `ready` for function rules. Prefer `install({ dehydrated, rules })` as the client entry, with `PermixHydrate` for first-paint booleans. + +Vue, Solid, and Svelte use the same factory as React (`createPermix` from `permix/vue` etc.). Classic `usePermix(instance)` / `createComponents` / provider-from-raw-instance are gone. + +`permix/drizzle/legacy` is removed; use `permix/drizzle` with Drizzle v1. + +## `explain()` (from v4.x) + +`check()` is still boolean. Use `explain()` for `{ allowed, path, reasons }` when rule functions `return { allow, reason }`. diff --git a/docs/content/docs/quick-start.mdx b/docs/content/docs/quick-start.mdx index c6bc2196..acfabfea 100644 --- a/docs/content/docs/quick-start.mdx +++ b/docs/content/docs/quick-start.mdx @@ -58,7 +58,9 @@ permix ### Create an instance -To create a base instance, you need to provide a schema as a generic type to `createPermix` function that defines your permissions: +Prefer a generated catalog: mark paths with `permission()`, run `pnpm permix extract`, then `createPermix()`. See [Permission extraction](/docs/guide/extraction). + +Manual generics remain the escape hatch for dynamic keys: ```ts title="/lib/permix.ts" import { createPermix } from 'permix' @@ -85,7 +87,7 @@ You can setup your permissions by calling `setup` method on your instance in any // Call setupPermissions in your application export function setupPermissions() { - permix.setup({ + return permix.setup({ post: { create: true, read: true, @@ -150,6 +152,10 @@ Continuing from the quick start, you can now explore how Permix integrates with Integration with native Request and Response handlers. + + Integration with Astro middleware and locals. + + Integration with Hono via middleware. @@ -170,6 +176,14 @@ Continuing from the quick start, you can now explore how Permix integrates with Integration with TanStack Start. + + Integration with Nuxt via Nitro request isolation. + + + + Integration with React Router 7 middleware and hydration. + + Integration with Solid via provider and hook. @@ -194,6 +208,10 @@ Continuing from the quick start, you can now explore how Permix integrates with Schema-driven permissions from Drizzle tables. + + Entity types from Zod, Valibot, ArkType, and other Standard Schema validators. + + Integration with Effect services and layers. diff --git a/docs/package.json b/docs/package.json index 7b7c16f4..644bca86 100644 --- a/docs/package.json +++ b/docs/package.json @@ -9,27 +9,28 @@ "check-types": "fumadocs-mdx && tsc --noEmit", "dev": "vite dev", "postinstall": "fumadocs-mdx", - "prebuild": "cd ../permix && pnpm run build", - "predeploy": "cd ../permix && pnpm run build", "preview": "vite preview", "start": "node .output/server/index.mjs" }, "dependencies": { + "@base-ui/react": "^1.7.0", "@remixicon/react": "^4.9.0", "@tanstack/react-router": "^1.170.8", "@tanstack/react-router-devtools": "^1.167.0", "@tanstack/react-start": "^1.168.14", "@vercel/analytics": "^2.0.1", - "fumadocs-core": "^16.9.3", - "fumadocs-mdx": "^15.0.10", - "fumadocs-twoslash": "^3.2.0", - "fumadocs-ui": "^16.9.3", + "flexsearch": "^0.8.212", + "fumadocs-core": "^16.15.4", + "fumadocs-mdx": "^15.4.0", + "fumadocs-twoslash": "^3.3.0", + "fumadocs-ui": "npm:@fumadocs/base-ui@^16.15.4", + "mdast-util-from-markdown": "^2.0.2", "mermaid": "^11.15.0", "permix": "workspace:*", - "react": "^19.2.6", - "react-dom": "^19.2.6", + "react": "catalog:", + "react-dom": "catalog:", "tailwind-merge": "^3.6.0", - "vite": "^8.0.16" + "vite": "catalog:" }, "devDependencies": { "@orpc/server": "^1.14.4", @@ -37,10 +38,10 @@ "@trpc/server": "^11.17.0", "@types/express": "^5.0.6", "@types/mdx": "^2.0.13", - "@types/node": "^24.10.0", - "@types/react": "^19.2.15", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.2", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", "drizzle-orm": "^1.0.0-rc.3", "elysia": "^1.4.28", "express": "^5", @@ -48,6 +49,7 @@ "hono": "^4.12.23", "nitro": "^3.0.260522-beta", "tailwindcss": "^4.3.0", - "typescript": "^6.0.3" + "typescript": "catalog:", + "zod": "^4.4.3" } } diff --git a/docs/remark-include-changelog.ts b/docs/remark-include-changelog.ts new file mode 100644 index 00000000..2c134e8d --- /dev/null +++ b/docs/remark-include-changelog.ts @@ -0,0 +1,44 @@ +import { readFileSync } from 'node:fs' +import path from 'node:path' + +import { fromMarkdown } from 'mdast-util-from-markdown' + +interface MdastNode { + type: string + depth?: number + value?: string + children?: MdastNode[] +} + +const changelogPath = path.resolve(import.meta.dirname, '..', 'CHANGELOG.md') + +function headingText(node: MdastNode): string { + return (node.children ?? []) + .map((child) => child.value ?? '') + .join('') + .trim() +} + +export function remarkIncludeChangelog() { + return (tree: { children: MdastNode[] }, file: { path?: string }) => { + const filePath = file.path ?? '' + if (!filePath.endsWith('changelog.mdx')) { + return + } + + const parsed = fromMarkdown(readFileSync(changelogPath, 'utf-8')) as { + children: MdastNode[] + } + const first = parsed.children[0] + if ( + first && + first.type === 'heading' && + first.depth === 1 && + headingText(first).toLowerCase() === 'changelog' + ) { + parsed.children.shift() + } + + tree.children.push(...parsed.children) + } +} diff --git a/docs/source.config.ts b/docs/source.config.ts index 71c2d569..f66fd49f 100644 --- a/docs/source.config.ts +++ b/docs/source.config.ts @@ -2,22 +2,29 @@ import { rehypeCodeDefaultOptions, remarkMdxMermaid, } from 'fumadocs-core/mdx-plugins' +import { metaSchema, pageSchema } from 'fumadocs-core/source/schema' import { defineConfig, defineDocs } from 'fumadocs-mdx/config' import { transformerTwoslash } from 'fumadocs-twoslash' import { createFileSystemTypesCache } from 'fumadocs-twoslash/cache-fs' +import { remarkIncludeChangelog } from './remark-include-changelog' + export const docs = defineDocs({ dir: 'content/docs', docs: { + schema: pageSchema, postprocess: { includeProcessedMarkdown: true, }, }, + meta: { + schema: metaSchema, + }, }) export default defineConfig({ mdxOptions: { - remarkPlugins: [remarkMdxMermaid], + remarkPlugins: [remarkMdxMermaid, remarkIncludeChangelog], rehypeCodeOptions: { themes: { light: 'github-light', diff --git a/docs/src/components/sidebar-scroll.tsx b/docs/src/components/sidebar-scroll.tsx index 50e8ee06..b1eeb363 100644 --- a/docs/src/components/sidebar-scroll.tsx +++ b/docs/src/components/sidebar-scroll.tsx @@ -1,10 +1,18 @@ import { useRouterState } from '@tanstack/react-router' import { useEffect } from 'react' -function scrollActiveSidebarItem() { - const viewport = document.querySelector( - '#nd-sidebar [data-radix-scroll-area-viewport]' +function getSidebarViewport() { + return document.querySelector( + [ + '#nd-sidebar [data-slot="scroll-area-viewport"]', + '#nd-sidebar [data-base-ui-scroll-area-viewport]', + '#nd-sidebar', + ].join(', ') ) +} + +function scrollActiveSidebarItem() { + const viewport = getSidebarViewport() const active = document.querySelector('#nd-sidebar [data-active="true"]') if (!viewport || !active) { diff --git a/docs/src/lib/shared.ts b/docs/src/lib/shared.ts index f8f21b35..92a881bf 100644 --- a/docs/src/lib/shared.ts +++ b/docs/src/lib/shared.ts @@ -7,3 +7,11 @@ export const gitConfig = { repo: 'permix', branch: 'main', } + +export function docsGithubUrl(path: string) { + const base = `https://github.com/${gitConfig.user}/${gitConfig.repo}/blob/${gitConfig.branch}` + if (path === 'changelog.mdx' || path === 'changelog.md') { + return `${base}/CHANGELOG.md` + } + return `${base}/docs/content/docs/${path}` +} diff --git a/docs/src/router.tsx b/docs/src/router.tsx index 4c3fd8f7..a13539de 100644 --- a/docs/src/router.tsx +++ b/docs/src/router.tsx @@ -9,7 +9,10 @@ export function getRouter() { routeTree, defaultPreload: 'intent', scrollRestoration: true, - scrollToTopSelectors: ['#nd-sidebar [data-radix-scroll-area-viewport]'], + scrollToTopSelectors: [ + '#nd-sidebar [data-slot="scroll-area-viewport"]', + '#nd-sidebar [data-base-ui-scroll-area-viewport]', + ], defaultNotFoundComponent: NotFound, }) } diff --git a/docs/src/routes/docs/$.tsx b/docs/src/routes/docs/$.tsx index d9d5649e..135f8c88 100644 --- a/docs/src/routes/docs/$.tsx +++ b/docs/src/routes/docs/$.tsx @@ -16,7 +16,7 @@ import { Suspense } from 'react' import { useMDXComponents } from '@/components/mdx' import { SidebarScrollFix } from '@/components/sidebar-scroll' import { baseOptions } from '@/lib/layout.shared' -import { gitConfig } from '@/lib/shared' +import { docsGithubUrl } from '@/lib/shared' import { slugsToMarkdownPath, source } from '@/lib/source' const serverLoader = createServerFn({ @@ -65,7 +65,7 @@ const clientLoader = browserCollections.docs.createClientLoader({ diff --git a/docs/src/styles/app.css b/docs/src/styles/app.css index 796799a4..f4e4ebfb 100644 --- a/docs/src/styles/app.css +++ b/docs/src/styles/app.css @@ -3,6 +3,9 @@ @import 'fumadocs-ui/css/preset.css'; @import 'fumadocs-twoslash/twoslash.css'; +@source "../**/*.{ts,tsx}"; +@source "../../node_modules/fumadocs-ui/dist/**/*.js"; + html { scrollbar-gutter: stable; } @@ -12,6 +15,7 @@ html > body[data-scroll-locked] { --removed-body-scroll-bar-size: 0px !important; } -[data-radix-scroll-area-viewport] { +[data-slot='scroll-area-viewport'], +[data-base-ui-scroll-area-viewport] { overflow-y: auto !important; } diff --git a/docs/vite.config.ts b/docs/vite.config.ts index 8005021a..bd5c0a47 100644 --- a/docs/vite.config.ts +++ b/docs/vite.config.ts @@ -1,3 +1,5 @@ +import path from 'node:path' + import tailwindcss from '@tailwindcss/vite' import { tanstackStart } from '@tanstack/react-start/plugin/vite' import react from '@vitejs/plugin-react' @@ -5,11 +7,33 @@ import mdx from 'fumadocs-mdx/vite' import { nitro } from 'nitro/vite' import { defineConfig } from 'vite' +const changelogFile = path.resolve(import.meta.dirname, '../CHANGELOG.md') + export default defineConfig({ server: { port: 3000, + fs: { + allow: ['..'], + }, }, plugins: [ + { + name: 'watch-changelog', + configureServer(server) { + server.watcher.add(changelogFile) + }, + handleHotUpdate({ file, server }) { + if (file !== changelogFile) { + return + } + const changelogModule = [ + ...server.moduleGraph.urlToModuleMap.values(), + ].find((mod) => mod.file?.endsWith('changelog.mdx')) + if (changelogModule) { + return [changelogModule] + } + }, + }, mdx(), tailwindcss(), tanstackStart({ diff --git a/examples/astro/main.ts b/examples/astro/main.ts new file mode 100644 index 00000000..f9a403a8 --- /dev/null +++ b/examples/astro/main.ts @@ -0,0 +1,54 @@ +import { createServer } from 'node:http' + +import type { ValidateDefinition } from 'permix' +import { createPermix } from 'permix/astro' + +type PermissionsDefinition = ValidateDefinition<{ + user: ['read', 'write'] +}> + +const permix = createPermix({ + onForbidden: () => + Response.json( + { error: 'You do not have permission to access this resource' }, + { status: 403 } + ), +}) + +async function handle(request: Request): Promise { + const context = { request, locals: {} } + + return permix.setupMiddleware({ + user: { + read: true, + write: false, + }, + })(context, async () => { + const url = new URL(request.url) + + if (url.pathname === '/write') { + return permix.checkMiddleware('user.write')(context, () => + Response.json({ ok: true }) + ) + } + + if (url.pathname === '/permix') { + return Response.json({ + canRead: permix.getOrThrow(context).check('user.read'), + }) + } + + return Response.json({ + canRead: permix.getOrThrow(context).check('user.read'), + }) + }) +} + +createServer(async (req, res) => { + const request = new Request(`http://127.0.0.1:3000${req.url ?? '/'}`) + const response = await handle(request) + res.writeHead(response.status, Object.fromEntries(response.headers)) + res.end(Buffer.from(await response.arrayBuffer())) +}).listen(3000, () => { + console.log('Server is running on port 3000') +}) diff --git a/examples/astro/package.json b/examples/astro/package.json new file mode 100644 index 00000000..422ef367 --- /dev/null +++ b/examples/astro/package.json @@ -0,0 +1,16 @@ +{ + "name": "astro", + "private": true, + "type": "module", + "scripts": { + "check-types": "tsc --noEmit", + "start": "tsx main.ts" + }, + "dependencies": { + "permix": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.9.1", + "tsx": "^4.22.4" + } +} diff --git a/examples/astro/tsconfig.json b/examples/astro/tsconfig.json new file mode 100644 index 00000000..48ce3c61 --- /dev/null +++ b/examples/astro/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "types": ["node"], + "esModuleInterop": true, + "skipLibCheck": true + } +} diff --git a/examples/enum-based/package.json b/examples/enum-based/package.json index fa5a72a9..dabe0ff2 100644 --- a/examples/enum-based/package.json +++ b/examples/enum-based/package.json @@ -10,14 +10,14 @@ }, "dependencies": { "permix": "workspace:*", - "react": "^19.2.0", - "react-dom": "^19.2.6" + "react": "catalog:", + "react-dom": "catalog:" }, "devDependencies": { - "@types/react": "^19.2.15", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.2", - "typescript": "^6.0.3", - "vite": "^8.0.16" + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "typescript": "catalog:", + "vite": "catalog:" } } diff --git a/examples/enum-based/src/hooks/use-permissions.ts b/examples/enum-based/src/hooks/use-permissions.ts index 023cbb2f..fc23d858 100644 --- a/examples/enum-based/src/hooks/use-permissions.ts +++ b/examples/enum-based/src/hooks/use-permissions.ts @@ -1,7 +1 @@ -import { usePermix } from 'permix/react' - -import { permix } from '../lib/permix' - -export function usePermissions() { - return usePermix(permix) -} +export { usePermix as usePermissions } from '../lib/permix' diff --git a/examples/enum-based/src/lib/permix.ts b/examples/enum-based/src/lib/permix.ts index 44454a1e..cae1dd9f 100644 --- a/examples/enum-based/src/lib/permix.ts +++ b/examples/enum-based/src/lib/permix.ts @@ -1,11 +1,9 @@ -import { createPermix } from 'permix' -import { createComponents } from 'permix/react' +import { createPermix } from 'permix/react' import { PostPermission, UserPermission } from './permissions' import { getUser } from './user' -// Define permix instance -export const permix = createPermix<{ +export const { permix, PermixProvider, usePermix, Check } = createPermix<{ post: [ PostPermission.Create, PostPermission.Read, @@ -20,9 +18,6 @@ export const permix = createPermix<{ ] }>() -// Not necessary, but you can use components to check permissions -export const { Check } = createComponents(permix) - // Define the permissions for each role export const adminPermissions = permix.template({ post: { diff --git a/examples/enum-based/src/main.tsx b/examples/enum-based/src/main.tsx index a477ba08..dbb24289 100644 --- a/examples/enum-based/src/main.tsx +++ b/examples/enum-based/src/main.tsx @@ -1,13 +1,12 @@ -import { PermixProvider } from 'permix/react' import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import App from './App' -import { permix } from './lib/permix' +import { PermixProvider } from './lib/permix' createRoot(document.querySelector('#root')!).render( - + diff --git a/examples/express-trpc-react/client/src/App.tsx b/examples/express-trpc-react/client/src/App.tsx index e933b3ac..94cf7ff6 100644 --- a/examples/express-trpc-react/client/src/App.tsx +++ b/examples/express-trpc-react/client/src/App.tsx @@ -3,9 +3,8 @@ import { useEffect, useState } from 'react' import { getRules } from '@/shared/permix' import type { RouterOutput } from '@/shared/trpc' -import { Check } from './components/permix' import { usePermissions } from './hooks/use-permissions' -import { permix } from './permix' +import { Check, permix } from './permix' import { trpc } from './trpc' export default function App() { diff --git a/examples/express-trpc-react/client/src/components/permix.ts b/examples/express-trpc-react/client/src/components/permix.ts deleted file mode 100644 index 3baf309e..00000000 --- a/examples/express-trpc-react/client/src/components/permix.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createComponents } from 'permix/react' - -import { permix } from '../permix' - -export const { Check } = createComponents(permix) diff --git a/examples/express-trpc-react/client/src/hooks/use-permissions.ts b/examples/express-trpc-react/client/src/hooks/use-permissions.ts index 72c3d94a..96d23664 100644 --- a/examples/express-trpc-react/client/src/hooks/use-permissions.ts +++ b/examples/express-trpc-react/client/src/hooks/use-permissions.ts @@ -1,7 +1 @@ -import { usePermix } from 'permix/react' - -import { permix } from '../permix' - -export function usePermissions() { - return usePermix(permix) -} +export { usePermix as usePermissions } from '../permix' diff --git a/examples/express-trpc-react/client/src/main.tsx b/examples/express-trpc-react/client/src/main.tsx index b8797f4e..0557533b 100644 --- a/examples/express-trpc-react/client/src/main.tsx +++ b/examples/express-trpc-react/client/src/main.tsx @@ -1,13 +1,12 @@ -import { PermixProvider } from 'permix/react' import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import App from './App.tsx' -import { permix } from './permix' +import { PermixProvider } from './permix' createRoot(document.querySelector('#root')!).render( - + diff --git a/examples/express-trpc-react/client/src/permix.ts b/examples/express-trpc-react/client/src/permix.ts index 550b59d1..5202b632 100644 --- a/examples/express-trpc-react/client/src/permix.ts +++ b/examples/express-trpc-react/client/src/permix.ts @@ -1,5 +1,6 @@ -import { createPermix } from 'permix' +import { createPermix } from 'permix/react' import type { PermissionsDefinition } from '@/shared/permix' -export const permix = createPermix() +export const { permix, PermixProvider, usePermix, Check } = + createPermix() diff --git a/examples/express-trpc-react/package.json b/examples/express-trpc-react/package.json index 617a0261..3d6f96ac 100644 --- a/examples/express-trpc-react/package.json +++ b/examples/express-trpc-react/package.json @@ -14,19 +14,20 @@ "cors": "^2.8.6", "express": "^5.1.0", "permix": "workspace:*", - "react": "^19.2.0", - "react-dom": "^19.2.6", + "react": "catalog:", + "react-dom": "catalog:", "zod": "^4.4.3" }, "devDependencies": { "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/pg": "^8.20.0", - "@types/react": "^19.2.15", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.2", - "tsx": "^4.22.4", - "vite": "^8.0.16", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "tsx": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", "vite-tsconfig-paths": "^6.1.1" } } diff --git a/examples/express-trpc-react/tsconfig.json b/examples/express-trpc-react/tsconfig.json index 2c5b1e86..ece8100b 100644 --- a/examples/express-trpc-react/tsconfig.json +++ b/examples/express-trpc-react/tsconfig.json @@ -5,7 +5,6 @@ "lib": ["ES2020", "DOM", "DOM.Iterable"], "moduleDetection": "force", "useDefineForClassFields": true, - "ignoreDeprecations": "6.0", "module": "ESNext", "moduleResolution": "bundler", "paths": { diff --git a/examples/express/package.json b/examples/express/package.json index 3270a084..7fce8c1b 100644 --- a/examples/express/package.json +++ b/examples/express/package.json @@ -12,6 +12,7 @@ }, "devDependencies": { "@types/express": "^5.0.6", - "tsx": "^4.22.4" + "tsx": "catalog:", + "typescript": "catalog:" } } diff --git a/examples/extracted-catalog/package.json b/examples/extracted-catalog/package.json new file mode 100644 index 00000000..21f2707d --- /dev/null +++ b/examples/extracted-catalog/package.json @@ -0,0 +1,19 @@ +{ + "name": "extracted-catalog", + "private": true, + "type": "module", + "scripts": { + "catalog": "tsx src/generate.ts", + "catalog:check": "tsx src/generate.ts --check", + "check-types": "tsc --noEmit" + }, + "dependencies": { + "permix": "workspace:*", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/node": "catalog:", + "tsx": "catalog:", + "typescript": "catalog:" + } +} diff --git a/examples/extracted-catalog/permissions.generated.json b/examples/extracted-catalog/permissions.generated.json new file mode 100644 index 00000000..9c923b33 --- /dev/null +++ b/examples/extracted-catalog/permissions.generated.json @@ -0,0 +1,76 @@ +{ + "permissions": [ + { + "annotations": { + "area": "work-management", + "risk": "standard", + "surfaces": [ + "task-page", + "api", + "ai-tool" + ] + }, + "description": "Add comments from the task page, public API, or an AI tool.", + "key": "tasks.comment", + "references": [ + { + "column": 12, + "file": "src/permission-markers.ts", + "line": 4 + } + ], + "tags": [ + "tasks", + "collaboration" + ], + "title": "Comment on a task" + }, + { + "annotations": { + "area": "work-management", + "risk": "elevated", + "surfaces": [ + "task-page", + "api" + ] + }, + "description": "Permanently delete a task.", + "key": "tasks.delete", + "references": [ + { + "column": 11, + "file": "src/permission-markers.ts", + "line": 13 + } + ], + "title": "Delete a task" + }, + { + "key": "tasks.read", + "references": [ + { + "column": 9, + "file": "src/permission-markers.ts", + "line": 21 + } + ], + "title": "Read tasks" + }, + { + "annotations": { + "area": "organization", + "risk": "elevated" + }, + "key": "workspace.members.invite", + "references": [ + { + "column": 29, + "file": "src/permission-markers.ts", + "line": 24 + } + ], + "title": "Invite workspace members" + } + ], + "schemaVersion": 1 +} diff --git a/examples/extracted-catalog/src/generate.ts b/examples/extracted-catalog/src/generate.ts new file mode 100644 index 00000000..faaf3dfd --- /dev/null +++ b/examples/extracted-catalog/src/generate.ts @@ -0,0 +1,18 @@ +import { checkPermissions, generatePermissions } from 'permix/extractor' + +import { permissionMetadata } from './permission-metadata' + +const options = { + catalogOutput: 'permissions.generated.json', + metadata: permissionMetadata, + moduleOutput: 'src/permissions.generated.ts', +} as const + +if (process.argv.includes('--check')) { + const result = await checkPermissions(options) + if (!result.valid) { + throw new Error(`Stale artifacts: ${result.stale.join(', ')}`) + } +} else { + await generatePermissions(options) +} diff --git a/examples/extracted-catalog/src/permission-markers.ts b/examples/extracted-catalog/src/permission-markers.ts new file mode 100644 index 00000000..69b6a925 --- /dev/null +++ b/examples/extracted-catalog/src/permission-markers.ts @@ -0,0 +1,30 @@ +import { permission } from 'permix' + +export const taskPermissions = { + comment: permission({ + key: 'tasks.comment', + tags: ['tasks', 'collaboration'], + annotations: { + area: 'work-management', + risk: 'standard', + surfaces: ['task-page', 'api', 'ai-tool'], + }, + }), + delete: permission({ + key: 'tasks.delete', + annotations: { + area: 'work-management', + risk: 'elevated', + surfaces: ['task-page', 'api'], + }, + }), + read: permission('tasks.read'), +} as const + +export const inviteMember = permission({ + key: 'workspace.members.invite', + annotations: { + area: 'organization', + risk: 'elevated', + }, +}) diff --git a/examples/extracted-catalog/src/permission-metadata.ts b/examples/extracted-catalog/src/permission-metadata.ts new file mode 100644 index 00000000..16ef4fe0 --- /dev/null +++ b/examples/extracted-catalog/src/permission-metadata.ts @@ -0,0 +1,18 @@ +import { definePermissionConfig } from './permissions.generated' + +export const permissionMetadata = definePermissionConfig({ + 'tasks.comment': { + title: 'Comment on a task', + description: 'Add comments from the task page, public API, or an AI tool.', + }, + 'tasks.delete': { + title: 'Delete a task', + description: 'Permanently delete a task.', + }, + 'tasks.read': { + title: 'Read tasks', + }, + 'workspace.members.invite': { + title: 'Invite workspace members', + }, +}) diff --git a/examples/extracted-catalog/src/permissions.generated.ts b/examples/extracted-catalog/src/permissions.generated.ts new file mode 100644 index 00000000..e1c1f4ae --- /dev/null +++ b/examples/extracted-catalog/src/permissions.generated.ts @@ -0,0 +1,100 @@ +/* This file is generated by Permix. Do not edit it directly. */ +import { + createPermissionConfig, + createPermissionOverlay, +} from 'permix' +import type { + ApplyPermissionOverlay, + Definition as PermixDefinition, +} from 'permix' + +export type { PermissionReference } from 'permix/extractor' + +export const permissionKeys = [ + 'tasks.comment', + 'tasks.delete', + 'tasks.read', + 'workspace.members.invite', +] as const + +export type Permission = (typeof permissionKeys)[number] + +export const permissions = { + tasks: { + comment: 'tasks.comment', + delete: 'tasks.delete', + read: 'tasks.read', + }, + workspace: { + members: { + invite: 'workspace.members.invite', + }, + }, +} as const + +export const permissionMetadata = { + 'tasks.comment': { + title: 'Comment on a task', + description: 'Add comments from the task page, public API, or an AI tool.', + tags: [ + 'tasks', + 'collaboration', + ], + annotations: { + area: 'work-management', + risk: 'standard', + surfaces: [ + 'task-page', + 'api', + 'ai-tool', + ], + }, + }, + 'tasks.delete': { + title: 'Delete a task', + description: 'Permanently delete a task.', + annotations: { + area: 'work-management', + risk: 'elevated', + surfaces: [ + 'task-page', + 'api', + ], + }, + }, + 'tasks.read': { + title: 'Read tasks', + }, + 'workspace.members.invite': { + title: 'Invite workspace members', + annotations: { + area: 'organization', + risk: 'elevated', + }, + }, +} as const + +export const permissionDefinition = { + tasks: [ + 'comment', + 'delete', + 'read', + ], + workspace: { + members: [ + 'invite', + ], + }, +} as const + +export type ExtractedDefinition = typeof permissionDefinition + +export type Definition< + Overlay extends PermixDefinition = ExtractedDefinition, +> = ApplyPermissionOverlay + +export const definePermissionConfig = + createPermissionConfig() + +export const definePermissionOverlay = + createPermissionOverlay() diff --git a/examples/extracted-catalog/src/permix.ts b/examples/extracted-catalog/src/permix.ts new file mode 100644 index 00000000..25835ce2 --- /dev/null +++ b/examples/extracted-catalog/src/permix.ts @@ -0,0 +1,35 @@ +import { action, createPermix } from 'permix' +import { z } from 'zod' + +import type { Definition } from './permissions.generated' +import { definePermissionOverlay, permissions } from './permissions.generated' + +const taskSchema = z.object({ + taskId: z.string(), +}) + +const overlay = definePermissionOverlay({ + tasks: [ + action('comment', taskSchema, { required: true }), + action('delete', taskSchema, { required: true }), + ], +}) + +type AppDefinition = Definition + +export const permix = createPermix().setup({ + tasks: { + comment: ({ taskId }) => taskId.length > 0, + delete: ({ taskId }) => taskId.length > 0, + read: true, + }, + workspace: { + members: { + invite: true, + }, + }, +}) + +export function canComment(taskId: string): boolean { + return permix.check(permissions.tasks.comment, { taskId }) +} diff --git a/examples/extracted-catalog/tsconfig.json b/examples/extracted-catalog/tsconfig.json new file mode 100644 index 00000000..093180e5 --- /dev/null +++ b/examples/extracted-catalog/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["ESNext"], + "module": "ESNext", + "moduleResolution": "Bundler", + "moduleDetection": "force", + "types": ["node"], + "strict": true, + "exactOptionalPropertyTypes": true, + "noEmit": true, + "isolatedModules": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/examples/feature-flags/package.json b/examples/feature-flags/package.json index 1bef5b37..86ee93f6 100644 --- a/examples/feature-flags/package.json +++ b/examples/feature-flags/package.json @@ -10,14 +10,14 @@ }, "dependencies": { "permix": "workspace:*", - "react": "^19.2.0", - "react-dom": "^19.2.6" + "react": "catalog:", + "react-dom": "catalog:" }, "devDependencies": { - "@types/react": "^19.2.15", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.2", - "typescript": "^6.0.3", - "vite": "^8.0.16" + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "typescript": "catalog:", + "vite": "catalog:" } } diff --git a/examples/feature-flags/src/hooks/use-permissions.ts b/examples/feature-flags/src/hooks/use-permissions.ts index 023cbb2f..fc23d858 100644 --- a/examples/feature-flags/src/hooks/use-permissions.ts +++ b/examples/feature-flags/src/hooks/use-permissions.ts @@ -1,7 +1 @@ -import { usePermix } from 'permix/react' - -import { permix } from '../lib/permix' - -export function usePermissions() { - return usePermix(permix) -} +export { usePermix as usePermissions } from '../lib/permix' diff --git a/examples/feature-flags/src/lib/permix.ts b/examples/feature-flags/src/lib/permix.ts index 30f19afd..6b82d96a 100644 --- a/examples/feature-flags/src/lib/permix.ts +++ b/examples/feature-flags/src/lib/permix.ts @@ -1,17 +1,12 @@ -import { createPermix } from 'permix' -import { createComponents } from 'permix/react' +import { createPermix } from 'permix/react' import { getUser } from './user' -// Define permix instance with feature flags -export const permix = createPermix<{ +export const { permix, PermixProvider, usePermix, Check } = createPermix<{ darkMode: ['enabled'] betaFeatures: ['newUI', 'experimentalAPI'] }>() -// Not necessary, but you can use components to check permissions -export const { Check } = createComponents(permix) - // Define the feature flags for each role export const betaUserFeatures = permix.template({ darkMode: { diff --git a/examples/feature-flags/src/main.tsx b/examples/feature-flags/src/main.tsx index a477ba08..dbb24289 100644 --- a/examples/feature-flags/src/main.tsx +++ b/examples/feature-flags/src/main.tsx @@ -1,13 +1,12 @@ -import { PermixProvider } from 'permix/react' import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import App from './App' -import { permix } from './lib/permix' +import { PermixProvider } from './lib/permix' createRoot(document.querySelector('#root')!).render( - + diff --git a/examples/nest/main.ts b/examples/nest/main.ts new file mode 100644 index 00000000..291b4b7a --- /dev/null +++ b/examples/nest/main.ts @@ -0,0 +1,67 @@ +import 'reflect-metadata' +import { + Controller, + ForbiddenException, + Get, + Module, + Req, +} from '@nestjs/common' +import { APP_GUARD, NestFactory } from '@nestjs/core' +import type { ValidateDefinition } from 'permix' +import { createPermix } from 'permix/nest' + +type PermissionsDefinition = ValidateDefinition<{ + user: ['read', 'write'] +}> + +const permix = createPermix({ + onForbidden: () => { + throw new ForbiddenException({ + error: 'You do not have permission to access this resource', + }) + }, +}) + +@Controller() +class AppController { + @Get() + @permix.Check('user.read') + read() { + return 'Hello World' + } + + @Get('write') + @permix.Check('user.write') + write() { + return 'Hello World' + } + + @Get('permix') + inspect(@Req() req: { [key: PropertyKey]: unknown }) { + return { canRead: permix.getOrThrow(req).check('user.read') } + } +} + +@Module({ + controllers: [AppController], + providers: [ + { + provide: APP_GUARD, + useValue: permix.guard(() => ({ + user: { + read: true, + write: false, + }, + })), + }, + ], +}) +class AppModule {} + +async function bootstrap() { + const app = await NestFactory.create(AppModule) + await app.listen(3000) + console.log('Server is running on port 3000') +} + +bootstrap() diff --git a/examples/nest/package.json b/examples/nest/package.json new file mode 100644 index 00000000..425acf92 --- /dev/null +++ b/examples/nest/package.json @@ -0,0 +1,21 @@ +{ + "name": "nest", + "private": true, + "type": "module", + "scripts": { + "check-types": "tsc --noEmit", + "start": "tsx main.ts" + }, + "dependencies": { + "@nestjs/common": "^11.1.6", + "@nestjs/core": "^11.1.6", + "@nestjs/platform-express": "^11.1.6", + "permix": "workspace:*", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.2" + }, + "devDependencies": { + "tsx": "^4.22.4", + "typescript": "^6.0.3" + } +} diff --git a/examples/nest/tsconfig.json b/examples/nest/tsconfig.json new file mode 100644 index 00000000..c123cb28 --- /dev/null +++ b/examples/nest/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true + } +} diff --git a/examples/next/app/actions.ts b/examples/next/app/actions.ts index 45906646..991e3614 100644 --- a/examples/next/app/actions.ts +++ b/examples/next/app/actions.ts @@ -2,8 +2,12 @@ import { revalidatePath } from 'next/cache' import { cookies } from 'next/headers' +import { createPermix } from 'permix' import type { DemoRole } from '@/lib/auth' +import { getSession } from '@/lib/auth' +import type { PermissionsDefinition } from '@/lib/permissions' +import { rulesForSession } from '@/lib/permissions' export async function switchRole(formData: FormData) { const role = formData.get('role') @@ -21,3 +25,15 @@ export async function switchRole(formData: FormData) { cookieStore.set('demo-role', role satisfies DemoRole, { path: '/' }) revalidatePath('/', 'layout') } + +export async function createPost() { + const permix = createPermix().setup( + rulesForSession(await getSession()) + ) + + if (!permix.check('post.create')) { + return { ok: false as const, error: 'Forbidden' } + } + + return { ok: true as const, message: 'Post created (demo)' } +} diff --git a/examples/next/app/api/posts/route.ts b/examples/next/app/api/posts/route.ts index f487a04a..8e00a8fb 100644 --- a/examples/next/app/api/posts/route.ts +++ b/examples/next/app/api/posts/route.ts @@ -1,6 +1,14 @@ -import { permix } from '@/lib/permix' +import { createPermix } from 'permix' + +import { getSession } from '@/lib/auth' +import type { PermissionsDefinition } from '@/lib/permissions' +import { rulesForSession } from '@/lib/permissions' export async function POST() { + const permix = createPermix().setup( + rulesForSession(await getSession()) + ) + if (!permix.check('post.create')) { return Response.json({ error: 'Forbidden' }, { status: 403 }) } diff --git a/examples/next/app/features/post-list.tsx b/examples/next/app/features/post-list.tsx new file mode 100644 index 00000000..cb475d99 --- /dev/null +++ b/examples/next/app/features/post-list.tsx @@ -0,0 +1,75 @@ +import Link from 'next/link' +import { Suspense } from 'react' + +import { permix } from '@/lib/permix' +import type { Post } from '@/lib/permix' +import { getPosts } from '@/lib/posts' + +import { CreatePostForm } from '../components/create-post-form' +import { PermissionBadge } from '../components/permission-badge' +import { PrivateEditIsland, PrivateEditIslandSkeleton } from './private-edit' + +export async function PostList() { + const [posts, canCreate] = await Promise.all([ + getPosts(), + permix.check('post.create'), + ]) + + return ( +
+

Posts

+ {posts.map((post) => ( +
+
+
+

Post {post.id}

+

+ authorId: {post.authorId} +

+
+ + +
+
+
+ + Open page + + }> + + +
+
+
+ ))} + +
+ ) +} + +async function PostUpdateBadge({ post }: { post: Post }) { + const allowed = await permix.check('post.update', post) + return +} + +async function PostDeleteBadge({ post }: { post: Post }) { + const allowed = await permix.check('post.delete', post) + return +} + +export function PostListSkeleton() { + return ( +
+
+
+
+
+ ) +} diff --git a/examples/next/app/features/private-edit.tsx b/examples/next/app/features/private-edit.tsx new file mode 100644 index 00000000..71590c51 --- /dev/null +++ b/examples/next/app/features/private-edit.tsx @@ -0,0 +1,44 @@ +import { createPermix } from 'permix' + +import { getSession } from '@/lib/auth' +import type { PermissionsDefinition } from '@/lib/permissions' +import { rulesForSession } from '@/lib/permissions' +import { permix } from '@/lib/permix' +import { getPost } from '@/lib/posts' + +import { EditButton } from '../posts/[id]/edit-button' +import { Providers } from '../providers' + +async function readPostUpdatePayload(postId: string) { + 'use cache: private' + const session = await getSession() + const instance = createPermix().setup( + rulesForSession(session) + ) + const post = await getPost(postId) + if (!post || !instance.check('post.update', post)) { + return null + } + return { id: post.id, authorId: post.authorId } +} + +export async function PrivateEditIsland({ postId }: { postId: string }) { + const payload = await readPostUpdatePayload(postId) + if (!payload) { + return null + } + + const [state, session] = await Promise.all([permix.dehydrate(), getSession()]) + + return ( + + + + ) +} + +export function PrivateEditIslandSkeleton() { + return ( +
+ ) +} diff --git a/examples/next/app/features/public-read-badge.tsx b/examples/next/app/features/public-read-badge.tsx new file mode 100644 index 00000000..511d6d99 --- /dev/null +++ b/examples/next/app/features/public-read-badge.tsx @@ -0,0 +1,12 @@ +import { publicPermix } from '@/lib/permix' + +import { PermissionBadge } from '../components/permission-badge' + +export async function PublicReadBadge() { + 'use cache' + const allowed = await publicPermix.check('post.read') + + return ( + + ) +} diff --git a/examples/next/app/features/server-checks.tsx b/examples/next/app/features/server-checks.tsx new file mode 100644 index 00000000..b3ab9291 --- /dev/null +++ b/examples/next/app/features/server-checks.tsx @@ -0,0 +1,28 @@ +import { permix } from '@/lib/permix' +import { getPosts } from '@/lib/posts' + +import { PermissionBadge } from '../components/permission-badge' +import { SyncReadBadge } from './sync-read-badge' + +export async function ServerChecks() { + const posts = await getPosts() + const canCreate = await permix.check('post.create') + const canRead = await permix.check('post.read', posts[0]) + + return ( +
+

Server checks in this request

+
+ + + +
+
+ ) +} + +export function ServerChecksSkeleton() { + return ( +
+ ) +} diff --git a/examples/next/app/features/session-panel.tsx b/examples/next/app/features/session-panel.tsx new file mode 100644 index 00000000..307ebefb --- /dev/null +++ b/examples/next/app/features/session-panel.tsx @@ -0,0 +1,27 @@ +import { getDemoRole, getSession } from '@/lib/auth' + +import { RoleSwitcher } from '../components/role-switcher' + +export async function SessionPanel() { + const [session, role] = await Promise.all([getSession(), getDemoRole()]) + + return ( +
+
+
+

Current session

+

+ {session ? session.label : 'Signed out (guest)'} +

+
+ +
+
+ ) +} + +export function SessionPanelSkeleton() { + return ( +
+ ) +} diff --git a/examples/next/app/features/sync-read-badge.tsx b/examples/next/app/features/sync-read-badge.tsx new file mode 100644 index 00000000..5bf3e40e --- /dev/null +++ b/examples/next/app/features/sync-read-badge.tsx @@ -0,0 +1,14 @@ +import { permix } from '@/lib/permix' + +import { PermissionBadge } from '../components/permission-badge' + +export function SyncReadBadge() { + const instance = permix.usePermix() + + return ( + + ) +} diff --git a/examples/next/app/layout.tsx b/examples/next/app/layout.tsx index 9806f987..f7afe313 100644 --- a/examples/next/app/layout.tsx +++ b/examples/next/app/layout.tsx @@ -1,11 +1,6 @@ import type { Metadata } from 'next' import { Geist, Geist_Mono } from 'next/font/google' -import { getSession } from '@/lib/auth' -import { adminTemplate, guestTemplate, permix } from '@/lib/permix' - -import { Providers } from './providers' - import './globals.css' const geistSans = Geist({ @@ -23,39 +18,18 @@ export const metadata: Metadata = { description: 'Live example of the permix/next integration', } -export default async function RootLayout({ +export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode }>) { - const session = await getSession() - - if (session) { - permix.setup( - session.role === 'admin' - ? adminTemplate() - : { - post: { - create: true, - read: true, - update: (post) => post?.authorId === session.userId, - delete: false, - }, - } - ) - } else { - permix.setup(guestTemplate()) - } - return ( - - {children} - + {children} ) diff --git a/examples/next/app/page.tsx b/examples/next/app/page.tsx index 2aef4e59..06b09096 100644 --- a/examples/next/app/page.tsx +++ b/examples/next/app/page.tsx @@ -1,29 +1,19 @@ -import Link from 'next/link' +import { Suspense } from 'react' -import { getDemoRole, getSession } from '@/lib/auth' -import { permix } from '@/lib/permix' -import { getPosts } from '@/lib/posts' - -import { CreatePostForm } from './components/create-post-form' -import { PermissionBadge } from './components/permission-badge' -import { RoleSwitcher } from './components/role-switcher' -import { EditButton } from './posts/[id]/edit-button' - -export default async function Home() { - const [session, role, posts] = await Promise.all([ - getSession(), - getDemoRole(), - getPosts(), - ]) +import { PostList, PostListSkeleton } from './features/post-list' +import { PublicReadBadge } from './features/public-read-badge' +import { ServerChecks, ServerChecksSkeleton } from './features/server-checks' +import { SessionPanel, SessionPanelSkeleton } from './features/session-panel' +export default function Home() { return (
-
+

Permix + Next.js App Router

- Per-request permissions demo + Request-safe permissions demo

This example mirrors the{' '} @@ -33,76 +23,26 @@ export default async function Home() { > Next.js integration guide - . Rules are set once in the root layout, checked on the server in - pages and route handlers, then dehydrated for client components. + . A rules resolver initializes one cached instance per request. Static + chrome stays in the App Shell; session-aware checks stream behind + Suspense.

+ + +
-
-
-
-

Current session

-

- {session ? session.label : 'Signed out (guest)'} -

-
- -
-
+ }> + + -
-

Server checks in this request

-
- - -
-
- -
-

Posts

- {posts.map((post) => ( -
-
-
-

Post {post.id}

-

- authorId: {post.authorId} -

-
- - -
-
-
- - Open page - - -
-
-
- ))} -
+ }> + + - + }> + +
) } diff --git a/examples/next/app/posts/[id]/edit-button.tsx b/examples/next/app/posts/[id]/edit-button.tsx index 63b0fb0a..79da33ab 100644 --- a/examples/next/app/posts/[id]/edit-button.tsx +++ b/examples/next/app/posts/[id]/edit-button.tsx @@ -1,12 +1,10 @@ 'use client' -import { usePermix } from 'permix/react' - -import { permix } from '@/app/providers' -import type { Post } from '@/lib/permix' +import { usePermix } from '@/lib/client-permix' +import type { Post } from '@/lib/permissions' export function EditButton({ post }: { post: Post }) { - const { check } = usePermix(permix) + const { check } = usePermix() if (!check('post.update', post)) { return null diff --git a/examples/next/app/posts/[id]/page.tsx b/examples/next/app/posts/[id]/page.tsx index 5929aa93..07cdf701 100644 --- a/examples/next/app/posts/[id]/page.tsx +++ b/examples/next/app/posts/[id]/page.tsx @@ -1,23 +1,20 @@ import Link from 'next/link' import { notFound } from 'next/navigation' +import { Suspense } from 'react' import { permix } from '@/lib/permix' import { getPost } from '@/lib/posts' -import { EditButton } from './edit-button' +import { + PrivateEditIsland, + PrivateEditIslandSkeleton, +} from '../../features/private-edit' -export default async function PostPage({ +export default function PostPage({ params, }: { params: Promise<{ id: string }> }) { - const { id } = await params - const post = await getPost(id) - - if (!post || !permix.check('post.read', post)) { - notFound() - } - return (
← Back to posts - -
-

- Post {post.id} -

-

- authorId: {post.authorId} -

-

- This page calls{' '} - - permix.check('post.read', post) - {' '} - on the server before rendering. -

-
- -
-
+ }> + {params.then(({ id }) => ( + + ))} +
) } + +async function PostArticle({ id }: { id: string }) { + const post = await getPost(id) + + if (!post || !(await permix.check('post.read', post))) { + notFound() + } + + return ( +
+

Post {post.id}

+

+ authorId: {post.authorId} +

+

+ This page calls{' '} + + await permix.check('post.read', post) + {' '} + on the server before rendering. The edit control is a privately cached + payload — a UI hint, not enforcement. +

+
+ }> + + +
+
+ ) +} + +function PostArticleSkeleton() { + return ( +
+ ) +} diff --git a/examples/next/app/providers.tsx b/examples/next/app/providers.tsx index 636c72de..43edea75 100644 --- a/examples/next/app/providers.tsx +++ b/examples/next/app/providers.tsx @@ -1,14 +1,16 @@ 'use client' import type { DehydratedState } from 'permix' -import { createPermix } from 'permix' -import { PermixHydrate, PermixProvider } from 'permix/react' import { useLayoutEffect } from 'react' import type { Session } from '@/lib/auth' -import type { PermissionsDefinition } from '@/lib/permix' - -const permix = createPermix() +import { + clientPermix, + PermixHydrate, + PermixProvider, +} from '@/lib/client-permix' +import { rulesForSession } from '@/lib/permissions' +import type { PermissionsDefinition } from '@/lib/permissions' function ClientRulesSetup({ session, @@ -18,14 +20,7 @@ function ClientRulesSetup({ children: React.ReactNode }) { useLayoutEffect(() => { - permix.setup({ - post: { - create: !!session, - read: true, - update: (post) => post?.authorId === session?.userId, - delete: session?.role === 'admin', - }, - }) + clientPermix.setup(rulesForSession(session)) }, [session]) return children @@ -41,12 +36,10 @@ export function Providers({ children: React.ReactNode }) { return ( - + {children} ) } - -export { permix } diff --git a/examples/next/lib/client-permix.ts b/examples/next/lib/client-permix.ts new file mode 100644 index 00000000..64a67907 --- /dev/null +++ b/examples/next/lib/client-permix.ts @@ -0,0 +1,11 @@ +import { createPermix } from 'permix/react' + +import type { PermissionsDefinition } from './permissions' + +export const { + permix: clientPermix, + PermixProvider, + PermixHydrate, + usePermix, + Check, +} = createPermix() diff --git a/examples/next/lib/permissions.ts b/examples/next/lib/permissions.ts new file mode 100644 index 00000000..9b89d945 --- /dev/null +++ b/examples/next/lib/permissions.ts @@ -0,0 +1,51 @@ +import { createTemplate } from 'permix' +import type { Rules, ValidateDefinition } from 'permix' + +import type { Session } from './auth' + +export interface Post { + id: string + authorId: string +} + +export type PermissionsDefinition = ValidateDefinition<{ + post: [ + { name: 'create'; type: Post }, + { name: 'read'; type: Post }, + { name: 'update'; type: Post }, + { name: 'delete'; type: Post }, + ] +}> + +export const adminTemplate = createTemplate({ + post: { create: true, read: true, update: true, delete: true }, +}) + +export const guestTemplate = createTemplate({ + post: { create: false, read: true, update: false, delete: false }, +}) + +export const publicReadTemplate = createTemplate({ + post: { create: false, read: true, update: false, delete: false }, +}) + +export function rulesForSession( + session: Session | null +): Rules { + if (!session) { + return guestTemplate() + } + + if (session.role === 'admin') { + return adminTemplate() + } + + return { + post: { + create: true, + read: true, + update: (post) => post?.authorId === session.userId, + delete: false, + }, + } +} diff --git a/examples/next/lib/permix.ts b/examples/next/lib/permix.ts index c3cf0b92..dfb46986 100644 --- a/examples/next/lib/permix.ts +++ b/examples/next/lib/permix.ts @@ -1,26 +1,16 @@ -import type { ValidateDefinition } from 'permix' import { createPermix } from 'permix/next' -export interface Post { - id: string - authorId: string -} +import { getSession } from './auth' +import type { PermissionsDefinition } from './permissions' +import { publicReadTemplate, rulesForSession } from './permissions' -export type PermissionsDefinition = ValidateDefinition<{ - post: [ - { name: 'create'; type: Post }, - { name: 'read'; type: Post }, - { name: 'update'; type: Post }, - { name: 'delete'; type: Post }, - ] -}> +export const permix = createPermix(async () => + rulesForSession(await getSession()) +) -export const permix = createPermix() +export const publicPermix = createPermix(() => + publicReadTemplate() +) -export const adminTemplate = permix.template({ - post: { create: true, read: true, update: true, delete: true }, -}) - -export const guestTemplate = permix.template({ - post: { create: false, read: true, update: false, delete: false }, -}) +export { adminTemplate, guestTemplate, rulesForSession } from './permissions' +export type { PermissionsDefinition, Post } from './permissions' diff --git a/examples/next/next.config.ts b/examples/next/next.config.ts index 2f186bb4..53d3bac5 100644 --- a/examples/next/next.config.ts +++ b/examples/next/next.config.ts @@ -2,6 +2,14 @@ import type { NextConfig } from 'next' const nextConfig: NextConfig = { transpilePackages: ['permix'], + cacheComponents: true, + partialPrefetching: true, + experimental: + process.env.EXPOSE_TESTING_API === '1' + ? { + exposeTestingApiInProductionBuild: true, + } + : undefined, } export default nextConfig diff --git a/examples/next/package.json b/examples/next/package.json index ab95c31f..ce9c8d82 100644 --- a/examples/next/package.json +++ b/examples/next/package.json @@ -8,17 +8,17 @@ "start": "next start" }, "dependencies": { - "next": "16.2.6", + "next": "16.3.3", "permix": "workspace:*", - "react": "19.2.4", - "react-dom": "19.2.4" + "react": "catalog:", + "react-dom": "catalog:" }, "devDependencies": { "@tailwindcss/postcss": "^4.3.0", - "@types/node": "^25.9.1", - "@types/react": "^19.2.15", - "@types/react-dom": "^19.2.3", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", "tailwindcss": "^4.3.0", - "typescript": "^6.0.3" + "typescript": "catalog:" } } diff --git a/examples/nuxt/main.ts b/examples/nuxt/main.ts new file mode 100644 index 00000000..6d1a3321 --- /dev/null +++ b/examples/nuxt/main.ts @@ -0,0 +1,66 @@ +import { createServer } from 'node:http' + +import { + createApp, + createRouter, + eventHandler, + setResponseStatus, + toNodeListener, +} from 'h3' +import type { ValidateDefinition } from 'permix' +import { createPermix } from 'permix/nuxt' + +type PermissionsDefinition = ValidateDefinition<{ + user: ['read', 'write'] +}> + +const permix = createPermix() + +const app = createApp() +const router = createRouter() + +function setup(event: Parameters[1]) { + permix.setup( + { + user: { + read: true, + write: false, + }, + }, + event + ) +} + +router.get( + '/', + eventHandler((event) => { + setup(event) + return { canRead: permix.get(event).check('user.read') } + }) +) + +router.get( + '/write', + eventHandler((event) => { + setup(event) + if (!permix.get(event).check('user.write')) { + setResponseStatus(event, 403) + return { error: 'Forbidden' } + } + return { ok: true } + }) +) + +router.get( + '/state', + eventHandler((event) => { + setup(event) + return permix.dehydrate(event) + }) +) + +app.use(router) + +createServer(toNodeListener(app)).listen(3000, () => { + console.log('Server is running on port 3000') +}) diff --git a/examples/nuxt/package.json b/examples/nuxt/package.json new file mode 100644 index 00000000..d655f2bc --- /dev/null +++ b/examples/nuxt/package.json @@ -0,0 +1,17 @@ +{ + "name": "nuxt", + "private": true, + "type": "module", + "scripts": { + "check-types": "tsc --noEmit", + "start": "tsx main.ts" + }, + "dependencies": { + "h3": "^1.15.4", + "permix": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.9.1", + "tsx": "^4.22.4" + } +} diff --git a/examples/nuxt/tsconfig.json b/examples/nuxt/tsconfig.json new file mode 100644 index 00000000..48ce3c61 --- /dev/null +++ b/examples/nuxt/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "types": ["node"], + "esModuleInterop": true, + "skipLibCheck": true + } +} diff --git a/examples/react-router/main.ts b/examples/react-router/main.ts new file mode 100644 index 00000000..e22135d0 --- /dev/null +++ b/examples/react-router/main.ts @@ -0,0 +1,59 @@ +import { createServer } from 'node:http' + +import type { ValidateDefinition } from 'permix' +import { createPermix } from 'permix/react-router' +import type { ReactRouterContext } from 'permix/react-router' + +type PermissionsDefinition = ValidateDefinition<{ + user: ['read', 'write'] +}> + +const permix = createPermix({ + onForbidden: () => + Response.json( + { error: 'You do not have permission to access this resource' }, + { status: 403 } + ), +}) + +function createContext(): ReactRouterContext { + const store = new Map() + return { + get: (key) => store.get(key), + set: (key, value) => { + store.set(key, value) + }, + } +} + +async function handle(request: Request): Promise { + const context = createContext() + + return permix.setupMiddleware({ + user: { + read: true, + write: false, + }, + })({ request, context }, async () => { + const url = new URL(request.url) + + if (url.pathname === '/write') { + return permix.checkMiddleware('user.write')({ request, context }, () => + Response.json({ ok: true }) + ) + } + + return Response.json({ + canRead: permix.getOrThrow(context).check('user.read'), + }) + }) +} + +createServer(async (req, res) => { + const request = new Request(`http://127.0.0.1:3000${req.url ?? '/'}`) + const response = await handle(request) + res.writeHead(response.status, Object.fromEntries(response.headers)) + res.end(Buffer.from(await response.arrayBuffer())) +}).listen(3000, () => { + console.log('Server is running on port 3000') +}) diff --git a/examples/react-router/package.json b/examples/react-router/package.json new file mode 100644 index 00000000..5dea0633 --- /dev/null +++ b/examples/react-router/package.json @@ -0,0 +1,16 @@ +{ + "name": "react-router", + "private": true, + "type": "module", + "scripts": { + "check-types": "tsc --noEmit", + "start": "tsx main.ts" + }, + "dependencies": { + "permix": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.9.1", + "tsx": "^4.22.4" + } +} diff --git a/examples/react-router/tsconfig.json b/examples/react-router/tsconfig.json new file mode 100644 index 00000000..48ce3c61 --- /dev/null +++ b/examples/react-router/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "types": ["node"], + "esModuleInterop": true, + "skipLibCheck": true + } +} diff --git a/examples/react/package.json b/examples/react/package.json index 02a32663..ad68ff5f 100644 --- a/examples/react/package.json +++ b/examples/react/package.json @@ -11,14 +11,14 @@ }, "dependencies": { "permix": "workspace:*", - "react": "^19.2.0", - "react-dom": "^19.2.6" + "react": "catalog:", + "react-dom": "catalog:" }, "devDependencies": { - "@types/react": "^19.2.15", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.2", - "typescript": "^6.0.3", - "vite": "^8.0.16" + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "typescript": "catalog:", + "vite": "catalog:" } } diff --git a/examples/react/src/App.tsx b/examples/react/src/App.tsx index 6e2015b1..2a532d4a 100644 --- a/examples/react/src/App.tsx +++ b/examples/react/src/App.tsx @@ -18,9 +18,13 @@ function App() { } }, [user]) + if (!isReady) { + return <>Is Permix ready? No + } + return ( <> - Is Permix ready? {isReady ? 'Yes' : 'No'} + Is Permix ready? Yes
My user is {user?.id ?? '...'}
diff --git a/examples/react/src/hooks/permissions.ts b/examples/react/src/hooks/permissions.ts index 023cbb2f..278e231e 100644 --- a/examples/react/src/hooks/permissions.ts +++ b/examples/react/src/hooks/permissions.ts @@ -1,7 +1,5 @@ -import { usePermix } from 'permix/react' - -import { permix } from '../lib/permix' +import { usePermix } from '../lib/permix' export function usePermissions() { - return usePermix(permix) + return usePermix() } diff --git a/examples/react/src/lib/permix.ts b/examples/react/src/lib/permix.ts index 4bf596bf..734b6bfc 100644 --- a/examples/react/src/lib/permix.ts +++ b/examples/react/src/lib/permix.ts @@ -1,10 +1,9 @@ -import { createPermix } from 'permix' -import { createComponents } from 'permix/react' +import { createPermix } from 'permix/react' import type { Post } from '../hooks/posts' import type { User } from '../hooks/user' -export const permix = createPermix<{ +export const { permix, PermixProvider, usePermix, Check } = createPermix<{ post: ['read', { name: 'edit'; type: Post }] }>() @@ -16,5 +15,3 @@ export function setupPermix(user: User) { }, }) } - -export const { Check } = createComponents(permix) diff --git a/examples/react/src/main.tsx b/examples/react/src/main.tsx index 83689952..8de0784e 100644 --- a/examples/react/src/main.tsx +++ b/examples/react/src/main.tsx @@ -1,15 +1,14 @@ -import { PermixProvider } from 'permix/react' import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import App from './App.tsx' -import { permix } from './lib/permix.ts' +import { PermixProvider } from './lib/permix.ts' import './index.css' createRoot(document.querySelector('#root')!).render( - + diff --git a/examples/rebac/package.json b/examples/rebac/package.json index 9ff84d58..bc9ffcbe 100644 --- a/examples/rebac/package.json +++ b/examples/rebac/package.json @@ -10,8 +10,8 @@ "permix": "workspace:*" }, "devDependencies": { - "@types/node": "^25.9.1", - "tsx": "^4.22.4", - "typescript": "^6.0.3" + "@types/node": "catalog:", + "tsx": "catalog:", + "typescript": "catalog:" } } diff --git a/examples/role-based/package.json b/examples/role-based/package.json index 762829ac..f80ce699 100644 --- a/examples/role-based/package.json +++ b/examples/role-based/package.json @@ -10,14 +10,14 @@ }, "dependencies": { "permix": "workspace:*", - "react": "^19.2.0", - "react-dom": "^19.2.6" + "react": "catalog:", + "react-dom": "catalog:" }, "devDependencies": { - "@types/react": "^19.2.15", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.2", - "typescript": "^6.0.3", - "vite": "^8.0.16" + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "typescript": "catalog:", + "vite": "catalog:" } } diff --git a/examples/role-based/src/hooks/use-permissions.ts b/examples/role-based/src/hooks/use-permissions.ts index 023cbb2f..fc23d858 100644 --- a/examples/role-based/src/hooks/use-permissions.ts +++ b/examples/role-based/src/hooks/use-permissions.ts @@ -1,7 +1 @@ -import { usePermix } from 'permix/react' - -import { permix } from '../lib/permix' - -export function usePermissions() { - return usePermix(permix) -} +export { usePermix as usePermissions } from '../lib/permix' diff --git a/examples/role-based/src/lib/permix.ts b/examples/role-based/src/lib/permix.ts index 0a69dbee..2f80e109 100644 --- a/examples/role-based/src/lib/permix.ts +++ b/examples/role-based/src/lib/permix.ts @@ -1,17 +1,12 @@ -import { createPermix } from 'permix' -import { createComponents } from 'permix/react' +import { createPermix } from 'permix/react' import { getUser } from './user' -// Define permix instance -export const permix = createPermix<{ +export const { permix, PermixProvider, usePermix, Check } = createPermix<{ post: ['create', 'read', 'update', 'delete'] user: ['create', 'read', 'update', 'delete'] }>() -// Not necessary, but you can use components to check permissions -export const { Check } = createComponents(permix) - // Define the permissions for each role export const adminPermissions = permix.template({ post: { diff --git a/examples/role-based/src/main.tsx b/examples/role-based/src/main.tsx index a477ba08..dbb24289 100644 --- a/examples/role-based/src/main.tsx +++ b/examples/role-based/src/main.tsx @@ -1,13 +1,12 @@ -import { PermixProvider } from 'permix/react' import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import App from './App' -import { permix } from './lib/permix' +import { PermixProvider } from './lib/permix' createRoot(document.querySelector('#root')!).render( - + diff --git a/examples/solid/package.json b/examples/solid/package.json index 81dceebf..66574579 100644 --- a/examples/solid/package.json +++ b/examples/solid/package.json @@ -14,8 +14,8 @@ "solid-js": "^1.9.13" }, "devDependencies": { - "typescript": "^6.0.3", - "vite": "^8.0.16", + "typescript": "catalog:", + "vite": "catalog:", "vite-plugin-solid": "^2.11.12" } } diff --git a/examples/solid/src/hooks/permissions.ts b/examples/solid/src/hooks/permissions.ts index 6ab33f25..fc23d858 100644 --- a/examples/solid/src/hooks/permissions.ts +++ b/examples/solid/src/hooks/permissions.ts @@ -1,7 +1 @@ -import { usePermix } from 'permix/solid' - -import { permix } from '../lib/permix' - -export function usePermissions() { - return usePermix(permix) -} +export { usePermix as usePermissions } from '../lib/permix' diff --git a/examples/solid/src/index.tsx b/examples/solid/src/index.tsx index 16761c34..f470f327 100644 --- a/examples/solid/src/index.tsx +++ b/examples/solid/src/index.tsx @@ -1,9 +1,8 @@ -import { PermixProvider } from 'permix/solid' import { render } from 'solid-js/web' /* @refresh reload */ import App from './App.tsx' -import { permix } from './lib/permix' +import { PermixProvider } from './lib/permix' import './index.css' @@ -11,7 +10,7 @@ const root = document.querySelector('#root') render( () => ( - + ), diff --git a/examples/solid/src/lib/permix.ts b/examples/solid/src/lib/permix.ts index c10e8936..04b1bf4a 100644 --- a/examples/solid/src/lib/permix.ts +++ b/examples/solid/src/lib/permix.ts @@ -1,10 +1,9 @@ -import { createPermix } from 'permix' -import { createComponents } from 'permix/solid' +import { createPermix } from 'permix/solid' import type { Post } from '../hooks/posts' import type { User } from '../hooks/user' -export const permix = createPermix<{ +export const { permix, PermixProvider, usePermix, Check } = createPermix<{ post: ['read', { name: 'edit'; type: Post }] }>() @@ -16,5 +15,3 @@ export function setupPermix(user: User) { }, }) } - -export const { Check } = createComponents(permix) diff --git a/examples/svelte/package.json b/examples/svelte/package.json index 5f6a066b..ed6afc06 100644 --- a/examples/svelte/package.json +++ b/examples/svelte/package.json @@ -20,10 +20,10 @@ "@sveltejs/adapter-auto": "^7.0.1", "@sveltejs/kit": "^2.61.1", "@sveltejs/vite-plugin-svelte": "^7.1.2", - "@types/node": "^25.9.1", + "@types/node": "catalog:", "svelte": "^5.55.2", "svelte-check": "^4.5.0", - "typescript": "^6.0.3", - "vite": "^8.0.16" + "typescript": "catalog:", + "vite": "catalog:" } } diff --git a/examples/svelte/src/lib/permissions.ts b/examples/svelte/src/lib/permissions.ts index f5921e1f..62f1091f 100644 --- a/examples/svelte/src/lib/permissions.ts +++ b/examples/svelte/src/lib/permissions.ts @@ -1,7 +1 @@ -import { usePermix } from 'permix/svelte' - -import { permix } from './permix' - -export function usePermissions() { - return usePermix(permix) -} +export { usePermix as usePermissions } from './permix' diff --git a/examples/svelte/src/lib/permix.ts b/examples/svelte/src/lib/permix.ts index 06dac03c..ead63fb5 100644 --- a/examples/svelte/src/lib/permix.ts +++ b/examples/svelte/src/lib/permix.ts @@ -1,10 +1,9 @@ -import { createPermix } from 'permix' -import { createComponents } from 'permix/svelte' +import { createPermix } from 'permix/svelte' import type { Post } from './posts' import type { User } from './user.svelte' -export const permix = createPermix<{ +export const { permix, PermixProvider, usePermix, Check } = createPermix<{ post: ['read', { name: 'edit'; type: Post }] }>() @@ -16,5 +15,3 @@ export function setupPermix(user: User) { }, }) } - -export const { Check } = createComponents(permix) diff --git a/examples/svelte/src/routes/+layout.svelte b/examples/svelte/src/routes/+layout.svelte index ec8ab700..a3b53b4f 100644 --- a/examples/svelte/src/routes/+layout.svelte +++ b/examples/svelte/src/routes/+layout.svelte @@ -1,7 +1,6 @@ @@ -10,6 +9,6 @@ - + {@render children()} diff --git a/examples/tanstack-start/package.json b/examples/tanstack-start/package.json index 442ab683..ab539aed 100644 --- a/examples/tanstack-start/package.json +++ b/examples/tanstack-start/package.json @@ -22,8 +22,8 @@ "@tanstack/router-plugin": "^1.132.0", "lucide-react": "^0.545.0", "permix": "workspace:*", - "react": "^19.2.0", - "react-dom": "^19.2.0", + "react": "catalog:", + "react-dom": "catalog:", "tailwindcss": "^4.1.18" }, "devDependencies": { @@ -32,13 +32,13 @@ "@tanstack/router-cli": "^1.132.0", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.0", - "@types/node": "^22.10.2", - "@types/react": "^19.2.0", - "@types/react-dom": "^19.2.0", - "@vitejs/plugin-react": "^6.0.1", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", "jsdom": "^28.1.0", - "typescript": "^6.0.2", - "vite": "^8.0.0", - "vitest": "^4.1.5" + "typescript": "catalog:", + "vite": "catalog:", + "vitest": "catalog:" } } diff --git a/examples/tanstack-start/src/lib/client-permix.ts b/examples/tanstack-start/src/lib/client-permix.ts new file mode 100644 index 00000000..6f55772e --- /dev/null +++ b/examples/tanstack-start/src/lib/client-permix.ts @@ -0,0 +1,10 @@ +import { createPermix } from 'permix/react' + +import type { PermissionsDefinition } from '@/lib/permix' + +export const { + permix: clientPermix, + PermixProvider, + PermixHydrate, + usePermix, +} = createPermix() diff --git a/examples/tanstack-start/src/lib/use-permix.ts b/examples/tanstack-start/src/lib/use-permix.ts index 6b6525df..a73126de 100644 --- a/examples/tanstack-start/src/lib/use-permix.ts +++ b/examples/tanstack-start/src/lib/use-permix.ts @@ -1,8 +1 @@ -import { useRouteContext } from '@tanstack/react-router' -import { usePermix as useReactPermix } from 'permix/react' - -export function usePermix() { - const { permix } = useRouteContext({ from: '__root__' }) - - return useReactPermix(permix) -} +export { usePermix } from '@/lib/client-permix' diff --git a/examples/tanstack-start/src/providers.tsx b/examples/tanstack-start/src/providers.tsx index 1005f128..0a35c439 100644 --- a/examples/tanstack-start/src/providers.tsx +++ b/examples/tanstack-start/src/providers.tsx @@ -1,47 +1,44 @@ import type { DehydratedState } from 'permix' -import { PermixHydrate, PermixProvider } from 'permix/react' import { useLayoutEffect } from 'react' import type { Session } from '@/lib/auth' -import type { PermissionsDefinition, PermixInstance } from '@/lib/permix' +import { + clientPermix, + PermixHydrate, + PermixProvider, +} from '@/lib/client-permix' +import type { PermissionsDefinition } from '@/lib/permix' import { createClientRules } from '@/lib/permix' function ClientRulesSetup({ - permix, state, session, children, }: { - permix: PermixInstance state: DehydratedState session: Session | null children: React.ReactNode }) { - // `hydrate()` only restores booleans — `setup()` brings back the - // function-based `post.update` rule and flips `isReady()`. Depends on - // `state` so it re-runs after every re-hydration, not just on session change. useLayoutEffect(() => { - permix.setup(createClientRules(session)) - }, [permix, state, session]) + clientPermix.install({ rules: createClientRules(session) }) + }, [state, session]) return children } export function Providers({ - permix, state, session, children, }: { - permix: PermixInstance state: DehydratedState session: Session | null children: React.ReactNode }) { return ( - + - + {children} diff --git a/examples/tanstack-start/src/routes/__root.tsx b/examples/tanstack-start/src/routes/__root.tsx index fc9206ff..d25441d3 100644 --- a/examples/tanstack-start/src/routes/__root.tsx +++ b/examples/tanstack-start/src/routes/__root.tsx @@ -46,10 +46,10 @@ export const Route = createRootRouteWithContext()({ }) function RootComponent() { - const { permix, state, session } = Route.useRouteContext() + const { state, session } = Route.useRouteContext() return ( - + ) diff --git a/examples/vue/package.json b/examples/vue/package.json index eef5064d..dad9a785 100644 --- a/examples/vue/package.json +++ b/examples/vue/package.json @@ -4,8 +4,8 @@ "private": true, "type": "module", "scripts": { - "build": "vue-tsc -b && vite build", - "check-types": "vue-tsc --noEmit", + "build": "node --import ../../permix/scripts/register-typescript6.mjs ./node_modules/vue-tsc/bin/vue-tsc.js -b && vite build", + "check-types": "node --import ../../permix/scripts/register-typescript6.mjs ./node_modules/vue-tsc/bin/vue-tsc.js --noEmit", "dev": "vite", "preview": "vite preview" }, @@ -16,8 +16,8 @@ "devDependencies": { "@vitejs/plugin-vue": "^6.0.7", "@vue/tsconfig": "^0.9.1", - "typescript": "^6.0.3", - "vite": "^8.0.16", + "typescript": "catalog:", + "vite": "catalog:", "vue-tsc": "^3.3.3" } } diff --git a/examples/vue/src/composables/permissions.ts b/examples/vue/src/composables/permissions.ts index 9482f84e..fc23d858 100644 --- a/examples/vue/src/composables/permissions.ts +++ b/examples/vue/src/composables/permissions.ts @@ -1,7 +1 @@ -import { usePermix } from 'permix/vue' - -import { permix } from '../lib/permix' - -export function usePermissions() { - return usePermix(permix) -} +export { usePermix as usePermissions } from '../lib/permix' diff --git a/examples/vue/src/lib/permix.ts b/examples/vue/src/lib/permix.ts index db3315c0..db00e831 100644 --- a/examples/vue/src/lib/permix.ts +++ b/examples/vue/src/lib/permix.ts @@ -1,10 +1,9 @@ -import { createPermix } from 'permix' -import { createComponents } from 'permix/vue' +import { createPermix } from 'permix/vue' import type { Post } from '../composables/posts' import type { User } from '../composables/user' -export const permix = createPermix<{ +export const { permix, PermixProvider, usePermix, Check } = createPermix<{ post: ['read', { name: 'edit'; type: Post }] }>() @@ -16,5 +15,3 @@ export function setupPermix(user: User) { }, }) } - -export const { Check } = createComponents(permix) diff --git a/examples/vue/src/main.ts b/examples/vue/src/main.ts index 4a301867..db17a8a8 100644 --- a/examples/vue/src/main.ts +++ b/examples/vue/src/main.ts @@ -1,15 +1,11 @@ -import { PermixProvider } from 'permix/vue' import { createApp } from 'vue' import App from './App.vue' -import { permix } from './lib/permix' +import { PermixProvider } from './lib/permix' import './style.css' createApp({ components: { PermixProvider, App }, - template: '', - setup() { - return { permix } - }, + template: '', }).mount('#app') diff --git a/ignores.ts b/ignores.ts index 9e77439e..01e287a2 100644 --- a/ignores.ts +++ b/ignores.ts @@ -3,15 +3,24 @@ export const ignorePatterns = [ '**/.next/**', '**/.turbo/**', '**/.vercel/**', + '**/.agents/**', + '**/.claude/**', '**/dist/**', '**/build/**', '**/coverage/**', '**/out/**', '**/node_modules/**', '**/_artifacts/**', + '**/.agents/**', '**/next-env.d.ts', '**/*.gen.ts', '**/*.generated.ts', + '**/*.generated.json', 'examples/svelte/src/lib/index.ts', 'pnpm-lock.yaml', + 'permix/benchmarks/.bundle-size/**', + 'permix/test/next/.scratch/**', + 'permix/test/next/playwright-report/**', + 'permix/test/next/test-results/**', + 'permix/test/next/**', ] diff --git a/oxlint.config.ts b/oxlint.config.ts index e29a4dc3..b62e5295 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -118,6 +118,20 @@ export default defineConfig({ }, overrides: [ ...vitestOverrides, + { + files: ['permix/test-d/**'], + rules: { + 'typescript/consistent-type-definitions': 'off', + }, + }, + { + files: ['permix/src/react/**'], + rules: { + 'typescript/no-explicit-any': 'error', + 'typescript/consistent-type-imports': 'error', + 'react/exhaustive-effect-dependencies': 'error', + }, + }, { files: nonReactGlobs, rules: { @@ -125,5 +139,12 @@ export default defineConfig({ 'react/jsx-key': 'off', }, }, + { + files: ['permix/src/nest/**', 'examples/nest/**'], + rules: { + 'class-methods-use-this': 'off', + 'typescript/no-extraneous-class': 'off', + }, + }, ], }) diff --git a/package.json b/package.json index 704b4041..bd21dbda 100644 --- a/package.json +++ b/package.json @@ -2,27 +2,37 @@ "private": true, "type": "module", "scripts": { - "build": "cd permix && pnpm run build", - "check-types": "turbo check-types", + "build": "turbo run build --filter=permix", + "check-types": "turbo run check-types", "format": "oxfmt --config ./oxfmt.config.ts --ignore-path .gitignore .", "format:check": "oxfmt --config ./oxfmt.config.ts --ignore-path .gitignore --check .", "lint": "oxlint --type-aware --config ./oxlint.config.ts --ignore-path .gitignore --deny-warnings .", "lint:fix": "oxlint --type-aware --config ./oxlint.config.ts --ignore-path .gitignore --fix --deny-warnings .", "prepare": "husky", "publish": "cd permix && pnpm publish", - "test": "cd permix && pnpm run test", - "update": "taze -r -I major" + "test": "turbo run test --filter=permix", + "test:next": "pnpm --filter permix build && pnpm --filter @permix/next-integration test", + "update": "taze -r -I major", + "verify": "pnpm run format:check && pnpm run lint && pnpm run test && pnpm run check-types && pnpm run build && pnpm --filter permix size:compare", + "verify:full": "pnpm run verify && pnpm run test:next", + "commitlint": "commitlint --config commitlint.config.mts" }, "devDependencies": { + "@commitlint/cli": "^20.5.0", + "@commitlint/config-conventional": "^20.5.0", "husky": "^9.1.7", "npm-run-all2": "^9.0.1", - "oxfmt": "^0.64.0", - "oxlint": "^1.79.0", - "oxlint-tsgolint": "^7.0.2001", + "oxfmt": "catalog:", + "oxlint": "catalog:", + "oxlint-tsgolint": "catalog:", "taze": "^19.14.1", - "turbo": "^2.9.16", - "typescript": "^6.0.3", - "ultracite": "^7.10.6" + "turbo": "catalog:", + "typescript": "catalog:", + "ultracite": "catalog:" }, - "packageManager": "pnpm@11.5.0" + "engines": { + "node": ">=22", + "pnpm": ">=11.0.0" + }, + "packageManager": "pnpm@11.24.0" } diff --git a/permix/.gitignore b/permix/.gitignore index 1d053641..b295b4e9 100644 --- a/permix/.gitignore +++ b/permix/.gitignore @@ -1,4 +1,5 @@ dist README.md +!benchmarks/README.md !skills/README.md .svelte-kit diff --git a/permix/benchmarks/README.md b/permix/benchmarks/README.md new file mode 100644 index 00000000..c94e2f0b --- /dev/null +++ b/permix/benchmarks/README.md @@ -0,0 +1,35 @@ +# Bundle-size benchmarks + +This directory measures realistic consumer entry points instead of comparing the published files on disk. Each fixture in `entries/` imports a public Permix subpath and keeps representative runtime code alive through bundling. + +## Workflow + +Build the package before measuring because fixtures resolve through the published `exports` map: + +```bash +pnpm run build +pnpm run size +pnpm run size:compare +``` + +- `size` prints the current minified and gzip sizes. +- `size:json` prints the same report as JSON for tooling. +- `size:compare` enforces hard budgets and allowed growth from the committed baseline. +- `size:update-baseline` rewrites `bundle-size-baseline.json` after an intentional, reviewed change. + +Generated bundles are written to `.bundle-size/` for inspection and are not committed. + +## Methodology + +The harness uses esbuild to produce one minified ESM bundle per fixture with `NODE_ENV=production`. Browser fixtures target ES2022; server fixtures target Node 22. Peer dependencies and their subpaths are external, while Permix runtime dependencies remain bundled. The Svelte fixture compiles packaged `.svelte` components with `svelte/compiler` before bundling. Gzip figures use level 9. + +Every public package export except `./package.json` must have a registered fixture. The `permix` extractor CLI is tracked separately from the `permix/extractor` API. Browser graphs are also checked through esbuild's metafile and emitted code to ensure `chokidar`, `oxc-parser`, and `tinyglobby` do not leak into client bundles. + +## Budget policy + +`bundle-size-budgets.json` has two safeguards: + +1. `maxBytes` and `maxGzipBytes` are hard ceilings, initially set with roughly 10–15% headroom over the measured baseline. +2. `maxDeltaBytes` and `maxDeltaGzipBytes` limit growth relative to the baseline. Delta limits are grouped by fixture size so small adapters cannot consume the full hard-budget headroom in one change. + +Do not raise a budget or refresh the baseline just to make CI pass. First inspect `.bundle-size/`, explain the increase in the pull request, and update the baseline and budget only when the added runtime cost is intentional. diff --git a/permix/benchmarks/bundle-size-baseline.json b/permix/benchmarks/bundle-size-baseline.json new file mode 100644 index 00000000..f370da18 --- /dev/null +++ b/permix/benchmarks/bundle-size-baseline.json @@ -0,0 +1,109 @@ +{ + "generatedAt": "2026-08-28T23:33:20.775Z", + "measurements": { + "core": { + "bytes": 4941, + "gzipBytes": 1937 + }, + "react-factory": { + "bytes": 7668, + "gzipBytes": 2876 + }, + "react-check": { + "bytes": 7697, + "gzipBytes": 2889 + }, + "vue": { + "bytes": 6950, + "gzipBytes": 2607 + }, + "solid": { + "bytes": 6667, + "gzipBytes": 2534 + }, + "svelte": { + "bytes": 7482, + "gzipBytes": 2759 + }, + "trpc": { + "bytes": 5987, + "gzipBytes": 2354 + }, + "orpc": { + "bytes": 5972, + "gzipBytes": 2346 + }, + "express": { + "bytes": 6521, + "gzipBytes": 2470 + }, + "hono": { + "bytes": 6402, + "gzipBytes": 2445 + }, + "node": { + "bytes": 6588, + "gzipBytes": 2515 + }, + "server": { + "bytes": 6534, + "gzipBytes": 2492 + }, + "astro": { + "bytes": 6318, + "gzipBytes": 2449 + }, + "elysia": { + "bytes": 6077, + "gzipBytes": 2332 + }, + "fastify": { + "bytes": 6268, + "gzipBytes": 2464 + }, + "drizzle": { + "bytes": 5430, + "gzipBytes": 2145 + }, + "standard-schema": { + "bytes": 7670, + "gzipBytes": 2873 + }, + "effect": { + "bytes": 5975, + "gzipBytes": 2289 + }, + "next": { + "bytes": 5363, + "gzipBytes": 2056 + }, + "next-config": { + "bytes": 16118, + "gzipBytes": 5605 + }, + "nuxt": { + "bytes": 5846, + "gzipBytes": 2250 + }, + "tanstack-start": { + "bytes": 6437, + "gzipBytes": 2462 + }, + "nest": { + "bytes": 6229, + "gzipBytes": 2436 + }, + "react-router": { + "bytes": 6186, + "gzipBytes": 2396 + }, + "extractor": { + "bytes": 14640, + "gzipBytes": 5102 + }, + "extractor-cli": { + "bytes": 19230, + "gzipBytes": 6594 + } + } +} diff --git a/permix/benchmarks/bundle-size-budgets.json b/permix/benchmarks/bundle-size-budgets.json new file mode 100644 index 00000000..99754e6d --- /dev/null +++ b/permix/benchmarks/bundle-size-budgets.json @@ -0,0 +1,158 @@ +{ + "core": { + "maxBytes": 5050, + "maxGzipBytes": 2050, + "maxDeltaBytes": 250, + "maxDeltaGzipBytes": 100 + }, + "react-factory": { + "maxBytes": 8000, + "maxGzipBytes": 3100, + "maxDeltaBytes": 350, + "maxDeltaGzipBytes": 150 + }, + "react-check": { + "maxBytes": 8900, + "maxGzipBytes": 3350, + "maxDeltaBytes": 350, + "maxDeltaGzipBytes": 150 + }, + "vue": { + "maxBytes": 8000, + "maxGzipBytes": 3000, + "maxDeltaBytes": 350, + "maxDeltaGzipBytes": 150 + }, + "solid": { + "maxBytes": 7700, + "maxGzipBytes": 2950, + "maxDeltaBytes": 350, + "maxDeltaGzipBytes": 150 + }, + "svelte": { + "maxBytes": 8650, + "maxGzipBytes": 3200, + "maxDeltaBytes": 350, + "maxDeltaGzipBytes": 150 + }, + "trpc": { + "maxBytes": 6250, + "maxGzipBytes": 2550, + "maxDeltaBytes": 250, + "maxDeltaGzipBytes": 100 + }, + "orpc": { + "maxBytes": 6250, + "maxGzipBytes": 2550, + "maxDeltaBytes": 250, + "maxDeltaGzipBytes": 100 + }, + "express": { + "maxBytes": 7500, + "maxGzipBytes": 2850, + "maxDeltaBytes": 250, + "maxDeltaGzipBytes": 100 + }, + "hono": { + "maxBytes": 7400, + "maxGzipBytes": 2850, + "maxDeltaBytes": 250, + "maxDeltaGzipBytes": 100 + }, + "node": { + "maxBytes": 7600, + "maxGzipBytes": 2900, + "maxDeltaBytes": 250, + "maxDeltaGzipBytes": 100 + }, + "server": { + "maxBytes": 7550, + "maxGzipBytes": 2900, + "maxDeltaBytes": 250, + "maxDeltaGzipBytes": 100 + }, + "astro": { + "maxBytes": 6600, + "maxGzipBytes": 2650, + "maxDeltaBytes": 250, + "maxDeltaGzipBytes": 100 + }, + "elysia": { + "maxBytes": 6350, + "maxGzipBytes": 2500, + "maxDeltaBytes": 250, + "maxDeltaGzipBytes": 100 + }, + "fastify": { + "maxBytes": 6550, + "maxGzipBytes": 2650, + "maxDeltaBytes": 250, + "maxDeltaGzipBytes": 100 + }, + "drizzle": { + "maxBytes": 5500, + "maxGzipBytes": 2300, + "maxDeltaBytes": 250, + "maxDeltaGzipBytes": 100 + }, + "standard-schema": { + "maxBytes": 8000, + "maxGzipBytes": 3100, + "maxDeltaBytes": 350, + "maxDeltaGzipBytes": 150 + }, + "effect": { + "maxBytes": 6200, + "maxGzipBytes": 2450, + "maxDeltaBytes": 250, + "maxDeltaGzipBytes": 100 + }, + "next": { + "maxBytes": 5550, + "maxGzipBytes": 2200, + "maxDeltaBytes": 250, + "maxDeltaGzipBytes": 100 + }, + "next-config": { + "maxBytes": 18100, + "maxGzipBytes": 6300, + "maxDeltaBytes": 500, + "maxDeltaGzipBytes": 150 + }, + "nuxt": { + "maxBytes": 6050, + "maxGzipBytes": 2400, + "maxDeltaBytes": 250, + "maxDeltaGzipBytes": 100 + }, + "tanstack-start": { + "maxBytes": 6750, + "maxGzipBytes": 2650, + "maxDeltaBytes": 250, + "maxDeltaGzipBytes": 100 + }, + "nest": { + "maxBytes": 6500, + "maxGzipBytes": 2650, + "maxDeltaBytes": 250, + "maxDeltaGzipBytes": 100 + }, + "react-router": { + "maxBytes": 6450, + "maxGzipBytes": 2600, + "maxDeltaBytes": 250, + "maxDeltaGzipBytes": 100 + }, + "extractor": { + "maxBytes": 16500, + "maxGzipBytes": 5750, + "maxDeltaBytes": 500, + "maxDeltaGzipBytes": 150 + }, + "extractor-cli": { + "maxBytes": 21600, + "maxGzipBytes": 7400, + "maxDeltaBytes": 500, + "maxDeltaGzipBytes": 150 + } +} diff --git a/permix/benchmarks/entries/astro.ts b/permix/benchmarks/entries/astro.ts new file mode 100644 index 00000000..4e277027 --- /dev/null +++ b/permix/benchmarks/entries/astro.ts @@ -0,0 +1,6 @@ +import { createPermix } from 'permix/astro' + +export const permissions = createPermix<{ post: ['read'] }>() +export const middleware = permissions.setupMiddleware({ + post: { read: true }, +}) diff --git a/permix/benchmarks/entries/core.ts b/permix/benchmarks/entries/core.ts new file mode 100644 index 00000000..7cd195ae --- /dev/null +++ b/permix/benchmarks/entries/core.ts @@ -0,0 +1,6 @@ +import { createPermix } from 'permix' + +export const permissions = createPermix<{ post: ['read'] }>({ + post: { read: true }, +}) +export const canRead = permissions.check('post.read') diff --git a/permix/benchmarks/entries/drizzle.ts b/permix/benchmarks/entries/drizzle.ts new file mode 100644 index 00000000..ab31175a --- /dev/null +++ b/permix/benchmarks/entries/drizzle.ts @@ -0,0 +1,3 @@ +import { createPermix, DEFAULT_DRIZZLE_ACTIONS } from 'permix/drizzle' + +export const drizzleAdapter = { createPermix, DEFAULT_DRIZZLE_ACTIONS } diff --git a/permix/benchmarks/entries/effect.ts b/permix/benchmarks/entries/effect.ts new file mode 100644 index 00000000..330c8894 --- /dev/null +++ b/permix/benchmarks/entries/effect.ts @@ -0,0 +1,6 @@ +import { createPermix } from 'permix/effect' + +export const permissions = createPermix<{ post: ['read'] }>({ + id: 'bundle-size', +}) +export const layer = permissions.layer({ post: { read: true } }) diff --git a/permix/benchmarks/entries/elysia.ts b/permix/benchmarks/entries/elysia.ts new file mode 100644 index 00000000..bcb8404a --- /dev/null +++ b/permix/benchmarks/entries/elysia.ts @@ -0,0 +1,4 @@ +import { createPermix } from 'permix/elysia' + +export const permissions = createPermix<{ post: ['read'] }>() +export const plugin = permissions.setupMiddleware({ post: { read: true } }) diff --git a/permix/benchmarks/entries/express.ts b/permix/benchmarks/entries/express.ts new file mode 100644 index 00000000..dd07718d --- /dev/null +++ b/permix/benchmarks/entries/express.ts @@ -0,0 +1,6 @@ +import { createPermix } from 'permix/express' + +export const permissions = createPermix<{ post: ['read'] }>() +export const middleware = permissions.setupMiddleware({ + post: { read: true }, +}) diff --git a/permix/benchmarks/entries/extractor.ts b/permix/benchmarks/entries/extractor.ts new file mode 100644 index 00000000..fae14ed7 --- /dev/null +++ b/permix/benchmarks/entries/extractor.ts @@ -0,0 +1,11 @@ +import { + extractPermissions, + generatePermissions, + validatePermissionCoverage, +} from 'permix/extractor' + +export const extractor = { + extractPermissions, + generatePermissions, + validatePermissionCoverage, +} diff --git a/permix/benchmarks/entries/fastify.ts b/permix/benchmarks/entries/fastify.ts new file mode 100644 index 00000000..ecf4c08d --- /dev/null +++ b/permix/benchmarks/entries/fastify.ts @@ -0,0 +1,4 @@ +import { createPermix } from 'permix/fastify' + +export const permissions = createPermix<{ post: ['read'] }>() +export const plugin = permissions.setupMiddleware({ post: { read: true } }) diff --git a/permix/benchmarks/entries/hono.ts b/permix/benchmarks/entries/hono.ts new file mode 100644 index 00000000..974ecfb2 --- /dev/null +++ b/permix/benchmarks/entries/hono.ts @@ -0,0 +1,6 @@ +import { createPermix } from 'permix/hono' + +export const permissions = createPermix<{ post: ['read'] }>() +export const middleware = permissions.setupMiddleware({ + post: { read: true }, +}) diff --git a/permix/benchmarks/entries/nest.ts b/permix/benchmarks/entries/nest.ts new file mode 100644 index 00000000..07073252 --- /dev/null +++ b/permix/benchmarks/entries/nest.ts @@ -0,0 +1,3 @@ +import { createPermix } from 'permix/nest' + +export const permissions = createPermix<{ post: ['read'] }>() diff --git a/permix/benchmarks/entries/next-config.ts b/permix/benchmarks/entries/next-config.ts new file mode 100644 index 00000000..d0732443 --- /dev/null +++ b/permix/benchmarks/entries/next-config.ts @@ -0,0 +1,6 @@ +import { createPermixPlugin } from 'permix/next/config' + +export const withPermix = createPermixPlugin({ + include: ['src/**/*.{ts,tsx}'], + watch: false, +}) diff --git a/permix/benchmarks/entries/next.ts b/permix/benchmarks/entries/next.ts new file mode 100644 index 00000000..9d066e12 --- /dev/null +++ b/permix/benchmarks/entries/next.ts @@ -0,0 +1,5 @@ +import { createPermix } from 'permix/next' + +export const permissions = createPermix<{ post: ['read'] }>(() => ({ + post: { read: true }, +})) diff --git a/permix/benchmarks/entries/node.ts b/permix/benchmarks/entries/node.ts new file mode 100644 index 00000000..efc2bf1e --- /dev/null +++ b/permix/benchmarks/entries/node.ts @@ -0,0 +1,6 @@ +import { createPermix } from 'permix/node' + +export const permissions = createPermix<{ post: ['read'] }>() +export const middleware = permissions.setupMiddleware({ + post: { read: true }, +}) diff --git a/permix/benchmarks/entries/nuxt.ts b/permix/benchmarks/entries/nuxt.ts new file mode 100644 index 00000000..a5823247 --- /dev/null +++ b/permix/benchmarks/entries/nuxt.ts @@ -0,0 +1,3 @@ +import { createPermix } from 'permix/nuxt' + +export const permissions = createPermix<{ post: ['read'] }>() diff --git a/permix/benchmarks/entries/orpc.ts b/permix/benchmarks/entries/orpc.ts new file mode 100644 index 00000000..f2c2c8b0 --- /dev/null +++ b/permix/benchmarks/entries/orpc.ts @@ -0,0 +1,4 @@ +import { createPermix } from 'permix/orpc' + +export const permissions = createPermix<{ post: ['read'] }>() +export const context = permissions.setupContext({ post: { read: true } }) diff --git a/permix/benchmarks/entries/react-check.ts b/permix/benchmarks/entries/react-check.ts new file mode 100644 index 00000000..31cc0ff1 --- /dev/null +++ b/permix/benchmarks/entries/react-check.ts @@ -0,0 +1,6 @@ +import { createPermix } from 'permix/react' + +export const bindings = createPermix<{ post: ['read'] }>() +bindings.permix.setup({ post: { read: true } }) + +export const { Check } = bindings diff --git a/permix/benchmarks/entries/react-factory.ts b/permix/benchmarks/entries/react-factory.ts new file mode 100644 index 00000000..55a4ecb9 --- /dev/null +++ b/permix/benchmarks/entries/react-factory.ts @@ -0,0 +1,4 @@ +import { createPermix } from 'permix/react' + +export const bindings = createPermix<{ post: ['read'] }>() +bindings.permix.setup({ post: { read: true } }) diff --git a/permix/benchmarks/entries/react-router.ts b/permix/benchmarks/entries/react-router.ts new file mode 100644 index 00000000..5ed28d9b --- /dev/null +++ b/permix/benchmarks/entries/react-router.ts @@ -0,0 +1,6 @@ +import { createPermix } from 'permix/react-router' + +export const permissions = createPermix<{ post: ['read'] }>() +export const middleware = permissions.setupMiddleware({ + post: { read: true }, +}) diff --git a/permix/benchmarks/entries/server.ts b/permix/benchmarks/entries/server.ts new file mode 100644 index 00000000..f414b85e --- /dev/null +++ b/permix/benchmarks/entries/server.ts @@ -0,0 +1,6 @@ +import { createPermix } from 'permix/server' + +export const permissions = createPermix<{ post: ['read'] }>() +export const middleware = permissions.setupMiddleware({ + post: { read: true }, +}) diff --git a/permix/benchmarks/entries/solid.ts b/permix/benchmarks/entries/solid.ts new file mode 100644 index 00000000..3272eb3d --- /dev/null +++ b/permix/benchmarks/entries/solid.ts @@ -0,0 +1,4 @@ +import { createPermix } from 'permix/solid' + +export const bindings = createPermix<{ post: ['read'] }>() +bindings.permix.setup({ post: { read: true } }) diff --git a/permix/benchmarks/entries/standard-schema.ts b/permix/benchmarks/entries/standard-schema.ts new file mode 100644 index 00000000..fed3edc2 --- /dev/null +++ b/permix/benchmarks/entries/standard-schema.ts @@ -0,0 +1,6 @@ +import { createPermix } from 'permix/standard-schema' + +export const permissions = createPermix({ + post: ['read'] as const, +}) +permissions.setup({ post: { read: true } }) diff --git a/permix/benchmarks/entries/svelte.ts b/permix/benchmarks/entries/svelte.ts new file mode 100644 index 00000000..962ce17c --- /dev/null +++ b/permix/benchmarks/entries/svelte.ts @@ -0,0 +1,4 @@ +import { createPermix } from 'permix/svelte' + +export const bindings = createPermix<{ post: ['read'] }>() +bindings.permix.setup({ post: { read: true } }) diff --git a/permix/benchmarks/entries/tanstack-start.ts b/permix/benchmarks/entries/tanstack-start.ts new file mode 100644 index 00000000..13e681ea --- /dev/null +++ b/permix/benchmarks/entries/tanstack-start.ts @@ -0,0 +1,6 @@ +import { createPermix } from 'permix/tanstack-start' + +export const permissions = createPermix<{ post: ['read'] }>() +export const middleware = permissions.setupMiddleware({ + post: { read: true }, +}) diff --git a/permix/benchmarks/entries/trpc.ts b/permix/benchmarks/entries/trpc.ts new file mode 100644 index 00000000..f7a95064 --- /dev/null +++ b/permix/benchmarks/entries/trpc.ts @@ -0,0 +1,4 @@ +import { createPermix } from 'permix/trpc' + +export const permissions = createPermix<{ post: ['read'] }>() +export const context = permissions.setupContext({ post: { read: true } }) diff --git a/permix/benchmarks/entries/vue.ts b/permix/benchmarks/entries/vue.ts new file mode 100644 index 00000000..e3c577a5 --- /dev/null +++ b/permix/benchmarks/entries/vue.ts @@ -0,0 +1,4 @@ +import { createPermix } from 'permix/vue' + +export const bindings = createPermix<{ post: ['read'] }>() +bindings.permix.setup({ post: { read: true } }) diff --git a/permix/package.json b/permix/package.json index dfbefd33..06bddc6a 100644 --- a/permix/package.json +++ b/permix/package.json @@ -7,14 +7,18 @@ "keywords": [ "access-control", "acl", + "astro", "authorization", "frontend", "javascript", + "nestjs", "nextjs", + "nuxt", "permissions", "permissions-management", "rbac", "react", + "react-router", "security", "solid", "svelte", @@ -35,11 +39,17 @@ "directory": "permix" }, "funding": "https://github.com/sponsors/letstri", + "bin": { + "permix": "./dist/extractor/cli.mjs" + }, "files": [ "dist", "skills" ], "type": "module", + "sideEffects": [ + "./dist/extractor/cli.mjs" + ], "main": "./dist/core/index.mjs", "types": "./dist/core/index.d.mts", "exports": { @@ -79,6 +89,10 @@ "types": "./dist/server/index.d.mts", "import": "./dist/server/index.mjs" }, + "./astro": { + "types": "./dist/astro/index.d.mts", + "import": "./dist/astro/index.mjs" + }, "./elysia": { "types": "./dist/elysia/index.d.mts", "import": "./dist/elysia/index.mjs" @@ -101,9 +115,9 @@ "types": "./dist/drizzle/index.d.mts", "import": "./dist/drizzle/index.mjs" }, - "./drizzle/legacy": { - "types": "./dist/drizzle/legacy/index.d.mts", - "import": "./dist/drizzle/legacy/index.mjs" + "./standard-schema": { + "types": "./dist/standard-schema/index.d.mts", + "import": "./dist/standard-schema/index.mjs" }, "./effect": { "types": "./dist/effect/index.d.mts", @@ -113,22 +127,54 @@ "types": "./dist/next/index.d.mts", "import": "./dist/next/index.mjs" }, + "./next/config": { + "types": "./dist/next/config.d.mts", + "import": "./dist/next/config.mjs" + }, + "./nuxt": { + "types": "./dist/nuxt/index.d.mts", + "import": "./dist/nuxt/index.mjs" + }, "./tanstack-start": { "types": "./dist/tanstack-start/index.d.mts", "import": "./dist/tanstack-start/index.mjs" + }, + "./nest": { + "types": "./dist/nest/index.d.mts", + "import": "./dist/nest/index.mjs" + }, + "./react-router": { + "types": "./dist/react-router/index.d.mts", + "import": "./dist/react-router/index.mjs" + }, + "./extractor": { + "types": "./dist/extractor/index.d.mts", + "import": "./dist/extractor/index.mjs" } }, "scripts": { - "build": "tsdown && pnpm run build:svelte", - "build:svelte": "svelte-package -i src/svelte -o dist/svelte && node ./scripts/build-svelte.ts", - "check-types": "tsc --build && svelte-check --tsconfig ./tsconfig.svelte.json", + "build": "node --import ./scripts/register-typescript6.mjs ./node_modules/tsdown/dist/run.mjs && pnpm run build:svelte", + "build:svelte": "node --import ./scripts/register-typescript6.mjs ./node_modules/@sveltejs/package/src/cli.js -i src/svelte -o dist/svelte && node ./scripts/build-svelte.ts", + "check-types": "tsc --build && node --import ./scripts/register-typescript6.mjs ./node_modules/svelte-check/bin/svelte-check --tsconfig ./tsconfig.svelte.json", + "type-check:compat:5.9": "node ./scripts/ensure-dist.mjs && node ./node_modules/typescript59/bin/tsc --build && node ./node_modules/typescript59/bin/tsc -p tsconfig.compat.json && node ./scripts/smoke-exports.ts", + "type-check:compat:6": "node ./scripts/ensure-dist.mjs && node ./node_modules/typescript6/bin/tsc6 --build && node ./node_modules/typescript6/bin/tsc6 -p tsconfig.compat.json --stableTypeOrdering && node ./scripts/smoke-exports.ts", + "type-check:compat:7": "node ./scripts/ensure-dist.mjs && tsc --build && tsc -p tsconfig.compat.json && node ./scripts/smoke-exports.ts", + "type-check:compat": "pnpm run type-check:compat:5.9 && pnpm run type-check:compat:6 && pnpm run type-check:compat:7", "prepublishOnly": "run-s check-types test build scripts:copy-readme skills:validate", "scripts:copy-readme": "node ./scripts/copy-readme.ts", + "size": "node --import ./scripts/register-typescript6.mjs ./scripts/bundle-size.ts", + "size:compare": "node --import ./scripts/register-typescript6.mjs ./scripts/bundle-size.ts --compare ./benchmarks/bundle-size-baseline.json", + "size:json": "node --import ./scripts/register-typescript6.mjs ./scripts/bundle-size.ts --json", + "size:update-baseline": "node --import ./scripts/register-typescript6.mjs ./scripts/bundle-size.ts --update-baseline", "skills:stale": "intent stale skills", "skills:validate": "intent validate skills", "test": "vitest run" }, "devDependencies": { + "@nestjs/common": "^11.2.3", + "@nestjs/core": "^11.2.3", + "@nestjs/platform-express": "^11.2.3", + "@nestjs/testing": "^11.2.3", "@solidjs/testing-library": "^0.8.10", "@sveltejs/package": "^2.5.7", "@sveltejs/vite-plugin-svelte": "^7.1.2", @@ -138,45 +184,70 @@ "@testing-library/react": "^16.3.2", "@testing-library/svelte": "^5.3.1", "@types/express": "^5.0.6", - "@types/node": "^25.9.1", - "@types/react": "^19.2.15", + "@types/node": "catalog:", + "@types/react": "catalog:", "@types/supertest": "^7.2.0", - "@vitejs/plugin-react": "^6.0.2", + "@vitejs/plugin-react": "catalog:", "@vitest/coverage-v8": "^4.1.8", "@vue/test-utils": "^2.4.10", + "arktype": "^2.2.3", + "chokidar": "catalog:", "drizzle-orm": "1.0.0-rc.3", "effect": "^3.21.2", + "esbuild": "catalog:", + "h3": "^1.15.11", "happy-dom": "^20.9.0", - "react-dom": "^19.2.6", + "oxc-parser": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.2", "supertest": "^7.2.2", "svelte": "^5.56.0", "svelte-check": "^4.5.0", + "tinyglobby": "catalog:", "tsdown": "^0.22.1", - "typescript": "^6.0.3", + "typescript": "catalog:", + "typescript59": "catalog:typescript-classic", + "typescript6": "catalog:typescript-classic", + "valibot": "^1.4.2", "vite-plugin-solid": "^2.11.12", - "vitest": "^4.1.8", + "vitest": "catalog:", "vue": "^3.5.17", "zod": "^4.4.3" }, "peerDependencies": { + "@nestjs/common": ">=10", + "@nestjs/core": ">=10", "@orpc/server": ">=1", "@tanstack/react-start": ">=1", "@trpc/server": ">=11", + "chokidar": ">=5", "drizzle-orm": ">=0.30.0 || >=1.0.0-rc.3", "effect": ">=3", "elysia": ">=1", "express": ">=4", "fastify": ">=5", "fastify-plugin": ">=5", - "hono": ">=4", - "next": ">=14", + "h3": ">=1.13", + "hono": ">=4.12.25", + "next": ">=15", + "oxc-parser": ">=0.147.0", "react": ">=18", "react-dom": ">=18", "solid-js": ">=1", "svelte": ">=5", + "tinyglobby": ">=0.2.17", + "typescript": ">=5.9 <8", "vue": ">=3" }, "peerDependenciesMeta": { + "@nestjs/common": { + "optional": true + }, + "@nestjs/core": { + "optional": true + }, "@orpc/server": { "optional": true }, @@ -204,9 +275,21 @@ "fastify-plugin": { "optional": true }, + "h3": { + "optional": true + }, "hono": { "optional": true }, + "chokidar": { + "optional": true + }, + "oxc-parser": { + "optional": true + }, + "tinyglobby": { + "optional": true + }, "next": { "optional": true }, @@ -224,12 +307,15 @@ }, "vue": { "optional": true + }, + "typescript": { + "optional": true } }, "engines": { "node": ">=22" }, - "packageManager": "pnpm@11.5.0", + "packageManager": "pnpm@11.24.0", "intent": { "repo": "letstri/permix", "docs": "../docs/content/docs" diff --git a/permix/scripts/bundle-size.test.ts b/permix/scripts/bundle-size.test.ts new file mode 100644 index 00000000..90106cb4 --- /dev/null +++ b/permix/scripts/bundle-size.test.ts @@ -0,0 +1,227 @@ +import { readFile, readdir } from 'node:fs/promises' +import path from 'node:path' + +import { describe, expect, it } from 'vitest' + +import { + BUNDLE_CASES, + compareMeasurements, + findForbiddenBrowserLeaks, + readBaselineMeasurements, + resolveBundleEntryPoint, + validateConfiguration, +} from './bundle-size' +import type { BundleBudget, BundleCase, PackageManifest } from './bundle-size' + +const packagePath = path.resolve(process.cwd(), 'package.json') +const budgetsPath = path.resolve( + process.cwd(), + 'benchmarks/bundle-size-budgets.json' +) +const fixturesPath = path.resolve(process.cwd(), 'benchmarks/entries') + +async function readJson(filePath: string): Promise { + return JSON.parse(await readFile(filePath, 'utf-8')) as T +} + +describe(compareMeasurements, () => { + const budgets = { + small: { + maxBytes: 120, + maxGzipBytes: 60, + maxDeltaBytes: 10, + maxDeltaGzipBytes: 5, + }, + } + + it('passes measurements within hard and delta budgets', () => { + expect( + compareMeasurements({ small: { bytes: 105, gzipBytes: 52 } }, budgets, { + small: { bytes: 100, gzipBytes: 50 }, + }) + ).toStrictEqual([]) + }) + + it('reports hard and baseline delta regressions', () => { + expect( + compareMeasurements({ small: { bytes: 130, gzipBytes: 70 } }, budgets, { + small: { bytes: 100, gzipBytes: 50 }, + }) + ).toStrictEqual([ + 'small: 130 bytes exceeds maxBytes 120', + 'small: 70 gzip bytes exceeds maxGzipBytes 60', + 'small: +30 bytes exceeds maxDeltaBytes 10', + 'small: +20 gzip bytes exceeds maxDeltaGzipBytes 5', + ]) + }) + + it('reports missing thresholds and measurements', () => { + expect( + compareMeasurements({ unbudgeted: { bytes: 1, gzipBytes: 1 } }, budgets) + ).toStrictEqual([ + 'Missing bundle-size threshold for "unbudgeted"', + 'Missing bundle-size measurement for "small"', + ]) + }) + + it('rejects malformed measurements and baselines', () => { + expect( + compareMeasurements( + { + small: { bytes: 100, gzipBytes: Number.NaN }, + }, + budgets + ) + ).toContain('Invalid bundle-size measurement "small.gzipBytes"') + + expect( + compareMeasurements({ small: { bytes: 100, gzipBytes: 50 } }, budgets, { + small: { bytes: 100, gzipBytes: Number.NaN }, + }) + ).toContain('Invalid bundle-size baseline "small.gzipBytes"') + }) + + it('requires delta thresholds when comparing to a baseline', () => { + expect( + compareMeasurements( + { small: { bytes: 100, gzipBytes: 50 } }, + { small: { maxBytes: 120, maxGzipBytes: 60 } }, + { small: { bytes: 100, gzipBytes: 50 } } + ) + ).toStrictEqual([ + 'Missing bundle-size threshold "small.maxDeltaBytes"', + 'Missing bundle-size threshold "small.maxDeltaGzipBytes"', + ]) + }) +}) + +describe(validateConfiguration, () => { + it('covers every current package export, bin, fixture, and threshold', async () => { + const [manifest, budgets, fixtures] = await Promise.all([ + readJson(packagePath), + readJson>(budgetsPath), + readdir(fixturesPath), + ]) + + expect( + validateConfiguration(manifest, BUNDLE_CASES, budgets, fixtures) + ).toStrictEqual([]) + }) + + it('reports an uncovered package export', () => { + const bundleCase: BundleCase = { + name: 'core', + subpath: '.', + platform: 'browser', + fixture: 'core.ts', + } + const manifest: PackageManifest = { + exports: { '.': {}, './new-adapter': {} }, + } + const budgets = { + core: { maxBytes: 1, maxGzipBytes: 1 }, + } + + expect(validateConfiguration(manifest, [bundleCase], budgets)).toContain( + 'Missing bundle-size fixture for package export "./new-adapter"' + ) + }) + + it('reports missing thresholds and fixture files', () => { + const bundleCase: BundleCase = { + name: 'core', + subpath: '.', + platform: 'browser', + fixture: 'core.ts', + } + const manifest: PackageManifest = { exports: { '.': {} } } + + expect(validateConfiguration(manifest, [bundleCase], {}, [])).toStrictEqual( + [ + 'Missing bundle-size threshold for "core"', + 'Missing bundle-size fixture file "core.ts"', + ] + ) + }) + + it('rejects malformed hard and delta thresholds', () => { + const bundleCase: BundleCase = { + name: 'core', + subpath: '.', + platform: 'browser', + fixture: 'core.ts', + } + const manifest: PackageManifest = { exports: { '.': {} } } + const malformedBudget = { + maxBytes: 1, + maxDeltaGzipBytes: -1, + } as BundleBudget + + expect( + validateConfiguration(manifest, [bundleCase], { core: malformedBudget }) + ).toStrictEqual([ + 'Invalid bundle-size threshold "core.maxGzipBytes"', + 'Invalid bundle-size threshold "core.maxDeltaGzipBytes"', + ]) + }) +}) + +describe(resolveBundleEntryPoint, () => { + it('reads the CLI entry point from the package manifest', () => { + const cliCase: BundleCase = { + name: 'extractor-cli', + subpath: 'bin:permix', + platform: 'node', + } + + expect( + resolveBundleEntryPoint(cliCase, { + exports: {}, + bin: { permix: './dist/custom-cli.mjs' }, + }) + ).toBe(path.resolve(process.cwd(), 'dist/custom-cli.mjs')) + }) +}) + +describe(readBaselineMeasurements, () => { + it('rejects a report without a measurements object', () => { + expect(() => readBaselineMeasurements({})).toThrow( + 'Invalid bundle-size baseline report' + ) + expect(() => readBaselineMeasurements({ measurements: null })).toThrow( + 'Invalid bundle-size baseline report' + ) + }) +}) + +describe(findForbiddenBrowserLeaks, () => { + it('detects forbidden packages in metafile inputs and bundled output', () => { + const metafile = { + inputs: { + 'node_modules/chokidar/index.js': { bytes: 1, imports: [] }, + 'src/index.ts': { bytes: 1, imports: [] }, + }, + outputs: {}, + } + + expect( + findForbiddenBrowserLeaks('const parser = "oxc-parser"', metafile) + ).toStrictEqual([ + 'chokidar (metafile input: node_modules/chokidar/index.js)', + 'oxc-parser (bundled output)', + ]) + }) + + it('passes a clean browser graph', () => { + const metafile = { + inputs: { + 'node_modules/react/index.js': { bytes: 1, imports: [] }, + }, + outputs: {}, + } + + expect( + findForbiddenBrowserLeaks('export const value = 1', metafile) + ).toStrictEqual([]) + }) +}) diff --git a/permix/scripts/bundle-size.ts b/permix/scripts/bundle-size.ts new file mode 100644 index 00000000..af2430ae --- /dev/null +++ b/permix/scripts/bundle-size.ts @@ -0,0 +1,655 @@ +import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { pathToFileURL } from 'node:url' +import { gzipSync } from 'node:zlib' + +import { build } from 'esbuild' +import type { BuildResult, Metafile, Plugin } from 'esbuild' +import { compile } from 'svelte/compiler' + +export type BundlePlatform = 'browser' | 'node' + +export interface BundleCase { + readonly name: string + readonly subpath: string + readonly platform: BundlePlatform + readonly fixture?: string +} + +export interface BundleMeasurement { + readonly bytes: number + readonly gzipBytes: number +} + +export interface BundleBudget { + readonly maxBytes: number + readonly maxGzipBytes: number + readonly maxDeltaBytes?: number + readonly maxDeltaGzipBytes?: number +} + +export interface BundleReport { + readonly generatedAt: string + readonly measurements: Record +} + +export interface PackageManifest { + readonly exports: Record + readonly peerDependencies?: Record + readonly bin?: Record | string +} + +export const BUNDLE_CASES: readonly BundleCase[] = [ + { name: 'core', subpath: '.', platform: 'browser', fixture: 'core.ts' }, + { + name: 'react-factory', + subpath: './react', + platform: 'browser', + fixture: 'react-factory.ts', + }, + { + name: 'react-check', + subpath: './react', + platform: 'browser', + fixture: 'react-check.ts', + }, + { name: 'vue', subpath: './vue', platform: 'browser', fixture: 'vue.ts' }, + { + name: 'solid', + subpath: './solid', + platform: 'browser', + fixture: 'solid.ts', + }, + { + name: 'svelte', + subpath: './svelte', + platform: 'browser', + fixture: 'svelte.ts', + }, + { name: 'trpc', subpath: './trpc', platform: 'node', fixture: 'trpc.ts' }, + { name: 'orpc', subpath: './orpc', platform: 'node', fixture: 'orpc.ts' }, + { + name: 'express', + subpath: './express', + platform: 'node', + fixture: 'express.ts', + }, + { name: 'hono', subpath: './hono', platform: 'node', fixture: 'hono.ts' }, + { name: 'node', subpath: './node', platform: 'node', fixture: 'node.ts' }, + { + name: 'server', + subpath: './server', + platform: 'node', + fixture: 'server.ts', + }, + { name: 'astro', subpath: './astro', platform: 'node', fixture: 'astro.ts' }, + { + name: 'elysia', + subpath: './elysia', + platform: 'node', + fixture: 'elysia.ts', + }, + { + name: 'fastify', + subpath: './fastify', + platform: 'node', + fixture: 'fastify.ts', + }, + { + name: 'drizzle', + subpath: './drizzle', + platform: 'node', + fixture: 'drizzle.ts', + }, + { + name: 'standard-schema', + subpath: './standard-schema', + platform: 'node', + fixture: 'standard-schema.ts', + }, + { + name: 'effect', + subpath: './effect', + platform: 'node', + fixture: 'effect.ts', + }, + { name: 'next', subpath: './next', platform: 'node', fixture: 'next.ts' }, + { + name: 'next-config', + subpath: './next/config', + platform: 'node', + fixture: 'next-config.ts', + }, + { name: 'nuxt', subpath: './nuxt', platform: 'node', fixture: 'nuxt.ts' }, + { + name: 'tanstack-start', + subpath: './tanstack-start', + platform: 'node', + fixture: 'tanstack-start.ts', + }, + { name: 'nest', subpath: './nest', platform: 'node', fixture: 'nest.ts' }, + { + name: 'react-router', + subpath: './react-router', + platform: 'node', + fixture: 'react-router.ts', + }, + { + name: 'extractor', + subpath: './extractor', + platform: 'node', + fixture: 'extractor.ts', + }, + { + name: 'extractor-cli', + subpath: 'bin:permix', + platform: 'node', + }, +] as const + +export const FORBIDDEN_BROWSER_PACKAGES = [ + 'chokidar', + 'oxc-parser', + 'tinyglobby', +] as const + +const packageRoot = path.resolve(import.meta.dirname, '..') +const entriesDirectory = path.join(packageRoot, 'benchmarks', 'entries') +const outputDirectory = path.join(packageRoot, 'benchmarks', '.bundle-size') +const budgetsPath = path.join( + packageRoot, + 'benchmarks', + 'bundle-size-budgets.json' +) +const baselinePath = path.join( + packageRoot, + 'benchmarks', + 'bundle-size-baseline.json' +) +const packagePath = path.join(packageRoot, 'package.json') +const sveltePlugin: Plugin = { + name: 'svelte', + setup(buildContext) { + buildContext.onLoad({ filter: /\.svelte$/ }, async ({ path: filePath }) => { + const source = await readFile(filePath, 'utf-8') + const result = compile(source, { + dev: false, + filename: filePath, + generate: 'client', + }) + return { + contents: result.js.code, + loader: 'js', + resolveDir: path.dirname(filePath), + } + }) + }, +} + +function binEntries(manifest: PackageManifest): string[] { + if (typeof manifest.bin === 'string') { + return ['bin:permix'] + } + return Object.keys(manifest.bin ?? {}).map((name) => `bin:${name}`) +} + +function binTarget( + manifest: PackageManifest, + subpath: string +): string | undefined { + const name = subpath.slice('bin:'.length) + if (typeof manifest.bin === 'string') { + return name === 'permix' ? manifest.bin : undefined + } + return manifest.bin?.[name] +} + +function isByteCount(value: unknown): value is number { + return Number.isFinite(value) && Number.isInteger(value) && Number(value) >= 0 +} + +export function validateBudget( + name: string, + budget: Partial, + requireDeltas = false +): string[] { + const errors: string[] = [] + for (const field of ['maxBytes', 'maxGzipBytes'] as const) { + if (!isByteCount(budget[field])) { + errors.push(`Invalid bundle-size threshold "${name}.${field}"`) + } + } + for (const field of ['maxDeltaBytes', 'maxDeltaGzipBytes'] as const) { + if (requireDeltas && budget[field] === undefined) { + errors.push(`Missing bundle-size threshold "${name}.${field}"`) + } else if (budget[field] !== undefined && !isByteCount(budget[field])) { + errors.push(`Invalid bundle-size threshold "${name}.${field}"`) + } + } + return errors +} + +function validateMeasurement( + name: string, + measurement: Partial, + source: 'baseline' | 'measurement' +): string[] { + const errors: string[] = [] + for (const field of ['bytes', 'gzipBytes'] as const) { + if (!isByteCount(measurement[field])) { + errors.push(`Invalid bundle-size ${source} "${name}.${field}"`) + } + } + return errors +} + +export function validateConfiguration( + manifest: PackageManifest, + cases: readonly BundleCase[], + budgets: Record, + availableFixtures?: readonly string[] +): string[] { + const errors: string[] = [] + const exportSubpaths = Object.keys(manifest.exports).filter( + (subpath) => subpath !== './package.json' + ) + const expectedSubpaths = new Set([...exportSubpaths, ...binEntries(manifest)]) + const coveredSubpaths = new Set(cases.map((bundleCase) => bundleCase.subpath)) + const caseNames = new Set() + const fixtures = new Set() + const availableFixtureSet = availableFixtures + ? new Set(availableFixtures) + : undefined + + for (const subpath of expectedSubpaths) { + if (!coveredSubpaths.has(subpath)) { + errors.push(`Missing bundle-size fixture for package export "${subpath}"`) + } + } + + for (const bundleCase of cases) { + if (!expectedSubpaths.has(bundleCase.subpath)) { + errors.push( + `Bundle-size case "${bundleCase.name}" covers unknown export "${bundleCase.subpath}"` + ) + } + if (caseNames.has(bundleCase.name)) { + errors.push(`Duplicate bundle-size case name "${bundleCase.name}"`) + } + const budget = budgets[bundleCase.name] + if (budget === undefined) { + errors.push(`Missing bundle-size threshold for "${bundleCase.name}"`) + } else { + errors.push(...validateBudget(bundleCase.name, budget)) + } + if (bundleCase.subpath.startsWith('bin:')) { + if (!binTarget(manifest, bundleCase.subpath)) { + errors.push(`Missing package bin target for "${bundleCase.subpath}"`) + } + } else if (bundleCase.fixture) { + if (fixtures.has(bundleCase.fixture)) { + errors.push(`Duplicate bundle-size fixture "${bundleCase.fixture}"`) + } + if (availableFixtureSet && !availableFixtureSet.has(bundleCase.fixture)) { + errors.push(`Missing bundle-size fixture file "${bundleCase.fixture}"`) + } + fixtures.add(bundleCase.fixture) + } else { + errors.push(`Missing bundle-size fixture for "${bundleCase.name}"`) + } + caseNames.add(bundleCase.name) + } + + for (const budgetName of Object.keys(budgets)) { + if (!caseNames.has(budgetName)) { + errors.push(`Bundle-size threshold has no case: "${budgetName}"`) + } + } + + return errors +} + +function packageFromInput(input: string): string | undefined { + const normalized = input.replaceAll('\\', '/') + const marker = 'node_modules/' + const markerIndex = normalized.lastIndexOf(marker) + const packagePath = + markerIndex === -1 + ? normalized + : normalized.slice(markerIndex + marker.length) + const parts = packagePath.split('/') + const first = parts[0] + if (!first) { + return undefined + } + return first.startsWith('@') && parts[1] ? `${first}/${parts[1]}` : first +} + +export function findForbiddenBrowserLeaks( + output: string, + metafile: Metafile, + forbidden: readonly string[] = FORBIDDEN_BROWSER_PACKAGES +): string[] { + const leaks = new Set() + + for (const input of Object.keys(metafile.inputs)) { + const packageName = packageFromInput(input) + if (packageName && forbidden.includes(packageName)) { + leaks.add(`${packageName} (metafile input: ${input})`) + } + } + + for (const packageName of forbidden) { + if (output.includes(packageName)) { + leaks.add(`${packageName} (bundled output)`) + } + } + + return [...leaks].toSorted() +} + +export function compareMeasurements( + measurements: Record, + budgets: Record, + baseline?: Record | null +): string[] { + const errors: string[] = [] + const compareBaseline = baseline ?? undefined + if (baseline === null) { + errors.push('Invalid bundle-size baseline report') + } + + for (const [name, measurement] of Object.entries(measurements)) { + const budget = budgets[name] + if (!budget) { + errors.push(`Missing bundle-size threshold for "${name}"`) + continue + } + const schemaErrors = [ + ...validateBudget(name, budget, baseline !== undefined), + ...validateMeasurement(name, measurement, 'measurement'), + ] + if (schemaErrors.length > 0) { + errors.push(...schemaErrors) + continue + } + + if (measurement.bytes > budget.maxBytes) { + errors.push( + `${name}: ${measurement.bytes} bytes exceeds maxBytes ${budget.maxBytes}` + ) + } + if (measurement.gzipBytes > budget.maxGzipBytes) { + errors.push( + `${name}: ${measurement.gzipBytes} gzip bytes exceeds maxGzipBytes ${budget.maxGzipBytes}` + ) + } + + if (!compareBaseline) { + continue + } + const previous = compareBaseline[name] + if (!previous) { + errors.push(`Missing baseline measurement for "${name}"`) + continue + } + const baselineErrors = validateMeasurement(name, previous, 'baseline') + if (baselineErrors.length > 0) { + errors.push(...baselineErrors) + continue + } + + const deltaBytes = measurement.bytes - previous.bytes + const deltaGzipBytes = measurement.gzipBytes - previous.gzipBytes + if ( + budget.maxDeltaBytes !== undefined && + deltaBytes > budget.maxDeltaBytes + ) { + errors.push( + `${name}: +${deltaBytes} bytes exceeds maxDeltaBytes ${budget.maxDeltaBytes}` + ) + } + if ( + budget.maxDeltaGzipBytes !== undefined && + deltaGzipBytes > budget.maxDeltaGzipBytes + ) { + errors.push( + `${name}: +${deltaGzipBytes} gzip bytes exceeds maxDeltaGzipBytes ${budget.maxDeltaGzipBytes}` + ) + } + } + + for (const name of Object.keys(budgets)) { + if (measurements[name] === undefined) { + errors.push(`Missing bundle-size measurement for "${name}"`) + } + } + + return errors +} + +function externalPackages(manifest: PackageManifest): string[] { + return Object.keys(manifest.peerDependencies ?? {}).flatMap((packageName) => [ + packageName, + `${packageName}/*`, + ]) +} + +export function resolveBundleEntryPoint( + bundleCase: BundleCase, + manifest: PackageManifest +): string { + if (bundleCase.subpath.startsWith('bin:')) { + const target = binTarget(manifest, bundleCase.subpath) + if (!target) { + throw new Error(`Missing package bin target for "${bundleCase.subpath}"`) + } + return path.resolve(packageRoot, target) + } + if (!bundleCase.fixture) { + throw new Error(`Missing bundle-size fixture for "${bundleCase.name}"`) + } + return path.join(entriesDirectory, bundleCase.fixture) +} + +async function buildBundleCase( + bundleCase: BundleCase, + external: string[], + manifest: PackageManifest +): Promise<{ measurement: BundleMeasurement; leaks: string[] }> { + const outfile = path.join(outputDirectory, `${bundleCase.name}.mjs`) + const result: BuildResult<{ metafile: true; write: false }> = await build({ + entryPoints: [resolveBundleEntryPoint(bundleCase, manifest)], + outfile, + bundle: true, + define: { + 'process.env.NODE_ENV': '"production"', + }, + external: [...external, 'astro:*', 'virtual:*'], + format: 'esm', + legalComments: 'none', + metafile: true, + minify: true, + platform: bundleCase.platform, + plugins: [sveltePlugin], + target: bundleCase.platform === 'browser' ? ['es2022'] : ['node22'], + treeShaking: true, + write: false, + }) + const outputFile = result.outputFiles[0] + if (!outputFile) { + throw new Error(`esbuild produced no output for "${bundleCase.name}"`) + } + + await writeFile(outfile, outputFile.contents) + const output = outputFile.text + const leaks = + bundleCase.platform === 'browser' + ? findForbiddenBrowserLeaks(output, result.metafile) + : [] + + return { + measurement: { + bytes: outputFile.contents.byteLength, + gzipBytes: gzipSync(outputFile.contents, { level: 9 }).byteLength, + }, + leaks, + } +} + +async function readJson(filePath: string): Promise { + return JSON.parse(await readFile(filePath, 'utf-8')) as T +} + +export function readBaselineMeasurements( + report: unknown +): Record { + if ( + typeof report !== 'object' || + report === null || + !('measurements' in report) || + typeof report.measurements !== 'object' || + report.measurements === null || + Array.isArray(report.measurements) + ) { + throw new Error('Invalid bundle-size baseline report') + } + return report.measurements as Record +} + +function formatBytes(bytes: number): string { + return `${(bytes / 1024).toFixed(2)} kB` +} + +function printHumanReport( + measurements: Record +): void { + const nameWidth = Math.max( + ...Object.keys(measurements).map((name) => name.length) + ) + console.log( + `${'Fixture'.padEnd(nameWidth)} ${'Minified'.padStart(10)} ${'Gzip'.padStart(10)}` + ) + for (const [name, measurement] of Object.entries(measurements)) { + console.log( + `${name.padEnd(nameWidth)} ${formatBytes(measurement.bytes).padStart(10)} ${formatBytes(measurement.gzipBytes).padStart(10)}` + ) + } +} + +interface CliOptions { + readonly json: boolean + readonly comparePath?: string + readonly updateBaseline: boolean +} + +function parseArguments(args: readonly string[]): CliOptions { + let comparePath: string | undefined + let json = false + let updateBaseline = false + + for (let index = 0; index < args.length; index++) { + const argument = args[index] + if (argument === '--json') { + json = true + continue + } + if (argument === '--update-baseline') { + updateBaseline = true + continue + } + if (argument === '--compare') { + comparePath = args[++index] + if (!comparePath) { + throw new Error('--compare requires a path') + } + continue + } + throw new Error(`Unknown argument: ${argument}`) + } + + if (updateBaseline && comparePath) { + throw new Error('--compare and --update-baseline cannot be used together') + } + + return { json, comparePath, updateBaseline } +} + +export async function runBundleSize( + args = process.argv.slice(2) +): Promise { + const options = parseArguments(args) + const [manifest, budgets, availableFixtures] = await Promise.all([ + readJson(packagePath), + readJson>(budgetsPath), + readdir(entriesDirectory), + ]) + const configurationErrors = validateConfiguration( + manifest, + BUNDLE_CASES, + budgets, + availableFixtures + ) + if (configurationErrors.length > 0) { + throw new Error(configurationErrors.join('\n')) + } + + await mkdir(outputDirectory, { recursive: true }) + const measurements: Record = {} + const leakErrors: string[] = [] + const external = externalPackages(manifest) + const bundledCases = await Promise.all( + BUNDLE_CASES.map(async (bundleCase) => ({ + bundleCase, + result: await buildBundleCase(bundleCase, external, manifest), + })) + ) + + for (const { bundleCase, result } of bundledCases) { + measurements[bundleCase.name] = result.measurement + for (const leak of result.leaks) { + leakErrors.push( + `${bundleCase.name}: forbidden browser dependency ${leak}` + ) + } + } + + const report: BundleReport = { + generatedAt: new Date().toISOString(), + measurements, + } + let baseline: Record | undefined + if (options.comparePath) { + const baselineReport = await readJson( + path.resolve(packageRoot, options.comparePath) + ) + baseline = readBaselineMeasurements(baselineReport) + } + const budgetErrors = compareMeasurements(measurements, budgets, baseline) + const errors = [...leakErrors, ...budgetErrors] + + if (options.updateBaseline && errors.length === 0) { + await writeFile(baselinePath, `${JSON.stringify(report, null, 2)}\n`) + } + + if (options.json) { + console.log(JSON.stringify(report, null, 2)) + } else { + printHumanReport(measurements) + } + + if (errors.length > 0) { + throw new Error(errors.join('\n')) + } +} + +const isMain = + process.argv[1] !== undefined && + import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href + +if (isMain) { + runBundleSize().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 + }) +} diff --git a/permix/scripts/ensure-dist.mjs b/permix/scripts/ensure-dist.mjs new file mode 100644 index 00000000..dc0e90b8 --- /dev/null +++ b/permix/scripts/ensure-dist.mjs @@ -0,0 +1,25 @@ +import { spawn } from 'node:child_process' +import { access } from 'node:fs/promises' +import path from 'node:path' + +const packageRoot = path.resolve(import.meta.dirname, '..') +const distEntry = path.join(packageRoot, 'dist/core/index.mjs') + +try { + await access(distEntry) +} catch { + const child = spawn('pnpm', ['run', 'build'], { + cwd: packageRoot, + stdio: 'inherit', + shell: false, + }) + const exitCode = await new Promise((resolve, reject) => { + child.once('error', reject) + child.once('exit', (code) => { + resolve(code ?? 1) + }) + }) + if (exitCode !== 0) { + process.exit(exitCode) + } +} diff --git a/permix/scripts/register-typescript6.mjs b/permix/scripts/register-typescript6.mjs new file mode 100644 index 00000000..7fa35848 --- /dev/null +++ b/permix/scripts/register-typescript6.mjs @@ -0,0 +1,26 @@ +import { createRequire, registerHooks } from 'node:module' +import { pathToFileURL } from 'node:url' + +const require = createRequire(import.meta.url) + +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === 'typescript/lib/tsc') { + // vue-tsc patches the TS 5.9 tsc shim; TS 6/7 lib/tsc is not patchable. + return { + shortCircuit: true, + url: pathToFileURL(require.resolve('typescript59/lib/tsc.js')).href, + } + } + + if (specifier === 'typescript' || specifier.startsWith('typescript/')) { + const mapped = specifier.replace(/^typescript(?=\/|$)/, 'typescript6') + return { + shortCircuit: true, + url: pathToFileURL(require.resolve(mapped)).href, + } + } + + return nextResolve(specifier, context) + }, +}) diff --git a/permix/scripts/smoke-exports.ts b/permix/scripts/smoke-exports.ts new file mode 100644 index 00000000..7d3b0da9 --- /dev/null +++ b/permix/scripts/smoke-exports.ts @@ -0,0 +1,37 @@ +const entrypoints = [ + ['.', await import('permix'), ['createPermix', 'permission']], + ['./trpc', await import('permix/trpc'), ['createPermix']], + ['./orpc', await import('permix/orpc'), ['createPermix']], + ['./express', await import('permix/express'), ['createPermix']], + ['./hono', await import('permix/hono'), ['createPermix']], + ['./node', await import('permix/node'), ['createPermix']], + [ + './server', + await import('permix/server'), + ['createPermix', 'createRequestKernel'], + ], + ['./elysia', await import('permix/elysia'), ['createPermix']], + ['./fastify', await import('permix/fastify'), ['createPermix']], + ['./drizzle', await import('permix/drizzle'), ['createPermix']], + ['./effect', await import('permix/effect'), ['createPermix']], + [ + './extractor', + await import('permix/extractor'), + ['extractPermissions', 'generatePermissions'], + ], + [ + './next/config', + await import('permix/next/config'), + ['createPermixPlugin', 'withPermix'], + ], +] as const + +for (const [subpath, module, expectedExports] of entrypoints) { + for (const exportName of expectedExports) { + if (!(exportName in module)) { + throw new Error( + `Missing ${exportName} export from permix${subpath === '.' ? '' : subpath}` + ) + } + } +} diff --git a/permix/skills/README.md b/permix/skills/README.md index f2616977..761cb98a 100644 --- a/permix/skills/README.md +++ b/permix/skills/README.md @@ -42,7 +42,7 @@ Restart Cursor or start a new agent chat so skills are picked up. | Skill | Intent id | When to use | | --- | --- | --- | | [permix-getting-started](./permix-getting-started/SKILL.md) | `permix#permix-getting-started` | New project, schema, `setup`, roles/templates | -| [permix](./permix/SKILL.md) | `permix#permix` | Everything past setup: `check`/ReBAC (`references/check.md`), React/Vue/Solid/Svelte + SSR (`references/frontend.md`), Express/Hono/Fastify/tRPC/oRPC middleware (`references/server.md`) | +| [permix](./permix/SKILL.md) | `permix#permix` | Everything past setup: `check`/ReBAC (`references/check.md`), React/Vue/Solid/Svelte + SSR (`references/frontend.md`), Express/Hono/Fastify/NestJS/tRPC/oRPC middleware (`references/server.md`) | ## Registry and version history @@ -59,6 +59,7 @@ The package includes the `tanstack-intent` npm keyword. Published versions are i | --- | --- | | Effect | https://permix.letstri.dev/docs/integrations/effect | | Drizzle ORM | https://permix.letstri.dev/docs/integrations/drizzle | +| Standard Schema (Zod, Valibot, …) | https://permix.letstri.dev/docs/integrations/standard-schema | | Events (`hook`, `hookOnce`) | https://permix.letstri.dev/docs/guide/events | Examples: https://github.com/letstri/permix/tree/main/examples @@ -72,4 +73,4 @@ pnpm run skills:validate # structure + packaging before publish pnpm run skills:stale # flag drift vs docs/sources ``` -CI runs `intent validate` on PRs and `intent stale` after releases (`.github/workflows/check-skills.yml`). Update `library_version` in SKILL frontmatter when cutting a release. +CI runs `intent validate` on PRs and `intent stale` after releases (`.github/workflows/check-skills.yml`). Release Please bumps `library_version` in SKILL frontmatter with the package version. diff --git a/permix/skills/permix-getting-started/SKILL.md b/permix/skills/permix-getting-started/SKILL.md index 9647154a..3e69aa56 100644 --- a/permix/skills/permix-getting-started/SKILL.md +++ b/permix/skills/permix-getting-started/SKILL.md @@ -5,7 +5,7 @@ description: >- metadata: type: core library: permix - library_version: '4.1.2' + library_version: '4.1.2' # x-release-please-version requires: [] sources: - 'letstri/permix:docs/content/docs/quick-start.mdx' @@ -14,6 +14,8 @@ sources: - 'letstri/permix:docs/content/docs/guide/instance.mdx' - 'letstri/permix:docs/content/docs/guide/events.mdx' - 'letstri/permix:docs/content/docs/migration-v3-to-v4.mdx' + - 'letstri/permix:docs/content/docs/migration-v4-to-v5.mdx' + - 'letstri/permix:docs/content/docs/integrations/standard-schema.mdx' - 'letstri/permix:permix/src/core/index.ts' --- @@ -32,7 +34,9 @@ pnpm add permix ## 1. Define the permission schema (once) -Create a shared module (e.g. `lib/permix.ts`). The generic on `createPermix()` is the source of truth for all paths and rules. +Prefer a generated catalog: mark paths with `permission()`, run `pnpm permix extract`, then `createPermix()`. See the **permix** skill, `references/extraction.md`. + +Manual generics remain the escape hatch for dynamic keys. Create a shared module (e.g. `lib/permix.ts`). **Nested resources** (most common): @@ -62,12 +66,34 @@ export const permix = createPermix<{ }>() ``` +**Entity data from a validator** (Zod, Valibot, ArkType, … via [Standard Schema](https://permix.letstri.dev/docs/integrations/standard-schema)): + +```ts +import { action, createPermix } from 'permix' +import { z } from 'zod' + +const postSchema = z.object({ id: z.string(), authorId: z.string() }) + +const definition = { + post: ['create', action('edit', postSchema, { required: true })], +} as const + +export const permix = createPermix() +``` + +Or `{ name: 'edit'; schema: typeof postSchema }` on the generic. CRUD-from-schemas: `createPermix` from `permix/standard-schema` (`{ validate: 'deny' | 'throw' }` to parse `check()` data). Core `check()` does not parse. + Every action you declare in `D` must appear in every `setup()` call (use `false` to deny). ## 2. Assign rules with `setup` +`setup()` returns a **new frozen instance**. It does not mutate the factory. + ```ts -permix.setup({ +const permix = createPermix<{ + post: ['create', 'read', 'update', 'delete'] + comment: ['create', 'read'] +}>().setup({ post: { create: true, read: true, @@ -82,8 +108,9 @@ permix.setup({ ``` - Static leaf: `boolean` -- Depends on resource at check time: `(data) => boolean` (see **permix** skill, `references/check.md`) -- Call `setup` after login, on route change, or when the active user/tenant changes — it **replaces** previous rules. +- Depends on resource at check time: `(data) => boolean` or `{ allow, reason }` (see **permix** skill, `references/check.md`) +- Call `setup` after login, on route change, or when the active user/tenant changes. Use the **returned** instance. Overlapping `setup()` on one factory does not leak across instances. +- On the server, stash the returned instance per request (`setupMiddleware`). Do not treat a module singleton `permix.setup(...)` as request-safe. ## 3. Optional: initial rules at construction diff --git a/permix/skills/permix/SKILL.md b/permix/skills/permix/SKILL.md index b3ebc1b5..19b7f0ce 100644 --- a/permix/skills/permix/SKILL.md +++ b/permix/skills/permix/SKILL.md @@ -1,17 +1,18 @@ --- name: permix description: >- - Applies Permix authorization once a schema exists: permix.check() paths and ReBAC callbacks, frontend bindings (permix/react, permix/vue, permix/solid, permix/svelte) with SSR dehydrate/hydrate for Next.js and TanStack Start, and server middleware (permix/express, hono, fastify, trpc, orpc, node, elysia). Use for anything past initial setup — checking permissions, gating UI, or protecting routes. For creating the schema and first `permix.setup()`, use permix-getting-started first. + Applies Permix authorization once a schema exists: permix.check() paths and ReBAC callbacks, frontend bindings (permix/react, permix/vue, permix/solid, permix/svelte) with SSR dehydrate/hydrate for Next.js, TanStack Start, Nuxt, and React Router, and server middleware (permix/express, hono, fastify, nest, trpc, orpc, node, elysia, astro). Use for anything past initial setup — checking permissions, gating UI, or protecting routes. For creating the schema and first `permix.setup()`, use permix-getting-started first. metadata: type: core library: permix - library_version: '4.1.2' + library_version: '4.1.2' # x-release-please-version requires: - permix-getting-started sources: - 'letstri/permix:docs/content/docs/guide/check.mdx' - 'letstri/permix:docs/content/docs/guide/rebac.mdx' - 'letstri/permix:docs/content/docs/guide/ready.mdx' + - 'letstri/permix:docs/content/docs/guide/extraction.mdx' - 'letstri/permix:docs/content/docs/guide/hydration.mdx' - 'letstri/permix:docs/content/docs/integrations/react.mdx' - 'letstri/permix:docs/content/docs/integrations/vue.mdx' @@ -19,13 +20,17 @@ sources: - 'letstri/permix:docs/content/docs/integrations/svelte.mdx' - 'letstri/permix:docs/content/docs/integrations/next.mdx' - 'letstri/permix:docs/content/docs/integrations/tanstack-start.mdx' + - 'letstri/permix:docs/content/docs/integrations/nuxt.mdx' + - 'letstri/permix:docs/content/docs/integrations/react-router.mdx' - 'letstri/permix:docs/content/docs/integrations/express.mdx' - 'letstri/permix:docs/content/docs/integrations/hono.mdx' - 'letstri/permix:docs/content/docs/integrations/fastify.mdx' + - 'letstri/permix:docs/content/docs/integrations/nest.mdx' - 'letstri/permix:docs/content/docs/integrations/trpc.mdx' - 'letstri/permix:docs/content/docs/integrations/orpc.mdx' - 'letstri/permix:docs/content/docs/integrations/node.mdx' - 'letstri/permix:docs/content/docs/integrations/server.mdx' + - 'letstri/permix:docs/content/docs/integrations/astro.mdx' - 'letstri/permix:docs/content/docs/integrations/elysia.mdx' - 'letstri/permix:permix/src/core/check.ts' --- @@ -36,13 +41,15 @@ Assumes a `permix` instance already exists (see **permix-getting-started**). Loa | Task | Reference | | --- | --- | -| `permix.check()` paths, callbacks, `~all`/`~any`, ReBAC/ABAC with entity data, `isReady` | [references/check.md](references/check.md) | -| React, Vue, Solid, or Svelte UI — `PermixProvider`, `usePermix`, `createComponents`, SSR `dehydrate`/`hydrate` for Next.js / TanStack Start | [references/frontend.md](references/frontend.md) | -| Protecting Express, Hono, Fastify, tRPC, oRPC, Node, or Elysia routes — `setupMiddleware`, `checkMiddleware` | [references/server.md](references/server.md) | +| `permix.check()` paths, callbacks, `~all`/`~any`, `explain()`, ReBAC/ABAC with entity data, `isReady` | [references/check.md](references/check.md) | +| Generate typed permission constants, metadata, and a `Definition` from source markers | [references/extraction.md](references/extraction.md) | +| React, Vue, Solid, or Svelte UI — `createPermix` from `permix/react` (or vue/solid/svelte); factory-bound Provider / `usePermix()` / `Check`; SSR `dehydrate`/`hydrate`/`install` for Next.js / TanStack Start / Nuxt / React Router | [references/frontend.md](references/frontend.md) | +| Protecting Express, Hono, Fastify, NestJS, tRPC, oRPC, Node, Elysia, or Astro routes — `setupMiddleware`, `checkMiddleware`, or Nest `guard` / `@Check` | [references/server.md](references/server.md) | ## Rules that apply everywhere - **Authorization must run on the server.** Client-side `check` (React/Vue/Solid/Svelte) is UX only — mirror every path with `checkMiddleware` in [references/server.md](references/server.md). - **Use the same schema and path strings** (`post.update`, not ad-hoc strings) across client hooks and server middleware, or types and behavior drift apart. - **`check` before `isReady`** throws `PermixNotReadyError` — gate UI with `isReady`/`isReadyAsync`, and call `setupMiddleware` before `checkMiddleware` on the server. -- **SSR `hydrate` alone is not enough.** It only restores booleans; call `setup` again on the client for function-based/ReBAC rules — see the SSR section of [references/frontend.md](references/frontend.md). +- **`setup()` / `hydrate()` return frozen instances.** They never mutate the factory. Stash the returned instance. Core `setup()` on a module singleton is not how you isolate requests — use adapter `setupMiddleware` (or `createPermix().setup(rules)` per request). +- **SSR `hydrate` alone is not enough.** It only restores booleans and does **not** fire the `setup` hook. Call `install({ rules })` (or `setup`) on the client for function-based/ReBAC rules — see the SSR section of [references/frontend.md](references/frontend.md). diff --git a/permix/skills/permix/references/check.md b/permix/skills/permix/references/check.md index e907e799..f7610dae 100644 --- a/permix/skills/permix/references/check.md +++ b/permix/skills/permix/references/check.md @@ -66,6 +66,25 @@ permix.check('post.update', post) // optional data if not required: true permix.check('post.delete', post) // required: true — data required ``` +Rule functions may return `{ allow, reason }` instead of a boolean. `check()` still returns `boolean`. Use `explain()` (same arguments) for tooltip copy — `explain().reasons` is the denial text, not a guessed string. + +```ts +permix.setup({ + post: { + delete: (post) => + post.authorId === currentUser.id + ? true + : { allow: false, reason: 'Only the author can delete this post' }, + }, +}) + +const { allowed, reasons } = permix.explain('post.delete', post) +``` + +UI hooks (`usePermix`) expose `explain` next to `check`. `'~all'` aggregates reasons from denied leaves. `dehydrate()` still stores booleans only. + +Prefer `schema: typeof postSchema` (or `action('edit', postSchema)`) when the entity already has a Zod/Valibot/ArkType schema — see https://permix.letstri.dev/docs/integrations/standard-schema. Core `check()` does not parse data. The `permix/standard-schema` factory can, via `{ validate: 'deny' | 'throw' }`. + **ReBAC pattern**: capture the **actor** in closures at `setup` time; pass the **resource** at `check` time. No separate ReBAC API. ## Readiness diff --git a/permix/skills/permix/references/extraction.md b/permix/skills/permix/references/extraction.md new file mode 100644 index 00000000..3789490a --- /dev/null +++ b/permix/skills/permix/references/extraction.md @@ -0,0 +1,47 @@ +# Permission extraction + +Use extraction when an application wants one typed permission vocabulary generated from actual source usage. + +## Mark and generate + +```ts +import { permission } from 'permix' + +export const comment = permission({ + key: 'tasks.comment', + title: 'Comment on tasks', + annotations: { + surfaces: ['web', 'api'], + }, +}) +``` + +Keys and metadata must be static. Run: + +```bash +pnpm permix extract +pnpm permix extract --watch +pnpm permix extract --check +``` + +The CLI and `withPermix` need optional peers `chokidar`, `oxc-parser`, and `tinyglobby`. Install them before extracting if they are not already in the project. + +The default outputs are `.permix/permissions.ts` and `.permix/permissions.json`. Use repeatable `--include` and `--exclude` flags for monorepos. + +## Consume the generated definition + +```ts +import { createPermix } from 'permix' +import { type Definition, permissions } from './.permix/permissions' + +const permix = createPermix() +permix.check(permissions.tasks.comment) +``` + +For payload data, call the generated `definePermissionOverlay()` with existing `action()` values, then use `Definition`. Never put validators in JSON or assume extraction enables runtime validation. + +Use the generated `definePermissionConfig()` to type central metadata. Inline metadata is the default; central metadata wins. Generate once before importing the generated helper into a new central config. + +Next.js projects can wrap config with `withPermix(nextConfig, options)` from `permix/next/config`. Use `createPermixPlugin(options)` when a preconfigured wrapper is easier to compose. + +Removing a marker only prunes generated TS/JSON. Always review persisted roles, provider policies, SQL/RLS, and other downstream systems separately. diff --git a/permix/skills/permix/references/frontend.md b/permix/skills/permix/references/frontend.md index 510f619c..b322ca81 100644 --- a/permix/skills/permix/references/frontend.md +++ b/permix/skills/permix/references/frontend.md @@ -1,49 +1,33 @@ # Permix — frontend (React / Vue / Solid / Svelte) -Pick the package subpath for your framework. Pattern is the same: one shared `permix` instance, call `setup` when the user is known, wrap the tree, check in components. +Pick the package subpath for your framework. Pattern is the same: `createPermix` from `permix/` returns a factory plus bound Provider, `usePermix()`, `Check`, and `PermixHydrate`. Call `setup` / `install` when the user is known. Docs: https://permix.letstri.dev/docs/integrations/react ## React -### Provider +Call `createPermix` from `permix/react` once at module scope — same name as `permix/next` and `permix/express`. Nested factories do not share state. + +```ts +import { createPermix } from 'permix/react' + +export const { permix, PermixProvider, PermixHydrate, usePermix, Check } = + createPermix<{ post: ['create', 'read'] }>() +``` ```tsx -import { PermixProvider } from 'permix/react' -import { permix } from './lib/permix' +import { PermixProvider, usePermix, Check } from './lib/permix' export function App() { return ( - + ) } -``` -### Setup after auth - -```ts -// e.g. after session loads -await loadUser() -permix.setup(roleRulesFor(user)) -``` - -### Hook (wrap once) - -```ts -// hooks/use-permissions.ts -import { usePermix } from 'permix/react' -import { permix } from '../lib/permix' - -export function usePermissions() { - return usePermix(permix) -} -``` - -```tsx function EditButton({ post }) { - const { check, isReady } = usePermissions() + const { check, isReady } = usePermix() if (!isReady) return null @@ -53,82 +37,79 @@ function EditButton({ post }) { } ``` -Pass the **same** `permix` instance to `PermixProvider` and `usePermix`. - -### Declarative `Check` component - -```ts -import { createComponents } from 'permix/react' - -export const { Check } = createComponents(permix) -``` - ```tsx Denied}> ``` -```tsx - - Hidden when allowed; shown when denied - +Supports React 18 and React 19. Native `useEffectEvent` is used on React 19.2+; React 18 uses a compatible fallback. + +### Setup after auth + +```ts +await loadUser() +permix.install({ rules: roleRulesFor(user) }) ``` ### SSR -Use `PermixHydrate` + call `setup` again on the client for function rules — see **SSR and hydration** below. +Use `PermixHydrate` for first-paint booleans, then `install({ rules })` on the client for function rules — see **SSR and hydration** below. ## Vue Docs: https://permix.letstri.dev/docs/integrations/vue -```vue - +```ts +import { createPermix } from 'permix/vue' - +export const { permix, PermixProvider, usePermix, Check } = createPermix<{ + post: ['create', 'read'] +}>() ``` -Use `usePermix` from `permix/vue` (same `setup` / `check` / `isReady` flow as React). +```ts +import { PermixProvider } from './lib/permix' + +createApp({ + components: { PermixProvider, App }, + template: '', +}).mount('#app') +``` + +`usePermix()` takes no instance argument. `isReady` is a computed ref. ## Solid Docs: https://permix.letstri.dev/docs/integrations/solid -Provider + hooks from `permix/solid`; mirror the React steps above. +Same factory from `permix/solid`. `isReady` is a function (`isReady()`). ## Svelte Docs: https://permix.letstri.dev/docs/integrations/svelte -Requires Svelte 5. Provider + hooks from `permix/svelte`; mirror the React steps above. +Requires Svelte 5. Same factory from `permix/svelte`. ```svelte - + {@render children()} ``` -`usePermix(permix)` returns `{ check, isReady }` where `isReady` is a reactive getter — access it as `permissions.isReady` (don't destructure). `createComponents(permix)` returns a typed `Check` component that uses `children` / `otherwise` snippets. +`isReady` is a reactive getter — access it as `permissions.isReady` (don't destructure). ## UX guidelines - Show loading or skeleton while `!isReady` — `check` returns `false` when rules are not ready in hooks. -- Hide destructive actions when denied; prefer disabling with tooltip only if you explain why. +- Hide destructive actions when denied. If you disable instead, set the tooltip from `explain().reasons` — do not invent a denial string. - Keep permission strings in sync with server middleware paths. ## SSR and hydration @@ -157,27 +138,28 @@ permix.hydrate(state) Function-based rules are **lost** in JSON (dehydration calls functions with no data; missing required data → `false`). -**Always call `setup` again on the client** with full rules (including closures): +**Always call `install({ rules })` (or `setup`) again on the client** with full rules (including closures): ```ts -permix.hydrate(serverState) -permix.setup(clientRulesForUser) // restores functions + sets ready +permix.hydrate(serverState) // booleans; does not fire setup +permix.install({ rules: clientRulesForUser }) // restores functions + sets ready ``` -Skipping client `setup` after hydrate leaves dynamic/ReBAC checks wrong. +Skipping client `install`/`setup` after hydrate leaves dynamic/ReBAC checks wrong. `hydrate()` does **not** fire the `setup` hook. ### React ```tsx -import { DehydratedState, PermixHydrate, PermixProvider } from 'permix/react' +import type { DehydratedState } from 'permix' +import { permix, PermixHydrate, PermixProvider } from './lib/permix' function App({ dehydratedState, }: { - dehydratedState: DehydratedState + dehydratedState: DehydratedState<{ post: ['create', 'read'] }> }) { return ( - + @@ -186,11 +168,13 @@ function App({ } ``` -Run client `permix.setup(...)` where you restore the session (e.g. after `PermixHydrate` mounts or in the same auth effect). +Run client `permix.install({ rules })` where you restore the session. `PermixHydrate` supplies dehydrated booleans on the first render. `isReady` stays `false` until client `setup()` / `install({ rules })`. + +### Next.js / TanStack Start / Nuxt / React Router -### Next.js / TanStack Start +Use framework helpers from `permix/next`, `permix/tanstack-start`, `permix/nuxt`, or `permix/react-router` when available — they wire dehydrate/hydrate into the framework data flow. Nuxt hydrates the client with `PermixProvider` / `PermixHydrate` from `permix/vue`. React Router 7 covers Remix; there is no `permix/remix` export. -Use framework helpers from `permix/next` or `permix/tanstack-start` when available — they wire dehydrate/hydrate into the framework data flow. +**Next.js App Router (`permix/next`)** — `createPermix(resolveRules)` caches one initialized instance per request. Async Server Components `await getPermix()` / `await check()`. Non-async Server Components call `usePermix()` (React `use()`); `check()` is sync at the call site. Keep layouts/pages synchronous; put async permission/data work in feature components behind page-owned Suspense. Cookie/session checks stay out of the shared App Shell. `"use cache"` / `next/root-params` belong in the **app** resolver, not inside Permix. `"use cache: private"` payloads should check permission before loading data and return `null` when denied; `notFound()`/`redirect()` stay uncached. Client `check` is a UI hint. Route Handlers and Server Actions must `createPermix()` + `setup()` a core instance per invocation — they do not share RSC `cache()`. In TanStack Start, `permix.get(context)` only works in server functions and server routes. To check inside `beforeLoad`/`loader`, put a core instance on the **router context** in `getRouter()` (`context: { permix }`), type it with `createRootRouteWithContext`, hydrate it in the root route's `beforeLoad`, then call `context.permix.check(...)` in any child route. Passing only the context type without the runtime value leaves `context.permix` undefined. @@ -200,23 +184,25 @@ Docs: - https://permix.letstri.dev/docs/integrations/next - https://permix.letstri.dev/docs/integrations/tanstack-start +- https://permix.letstri.dev/docs/integrations/nuxt +- https://permix.letstri.dev/docs/integrations/react-router ### Flow diagram ```text Server: setup(rules) → dehydrate() → send state -Client: hydrate(state) → setup(fullRules) → isReady() → check() / usePermix +Client: hydrate(state) → install({ rules: fullRules }) → isReady() → check() / usePermix ``` ### Pitfalls | Issue | Cause | | --- | --- | -| UI stuck not ready | `hydrate` without follow-up `setup` | +| UI stuck not ready | `hydrate` without follow-up `install({ rules })` / `setup` | | Wrong dynamic checks | Relying on dehydrated booleans only | | Mismatch server/client | Different schemas or missing actions in client `setup` | -For static-only permissions (all booleans), dehydrate + hydrate + `setup` with the same booleans is enough; still call `setup` to mark ready. +For static-only permissions (all booleans), dehydrate + hydrate + `install({ rules })` with the same booleans is enough; still call `install`/`setup` to mark ready. ## Examples in the Permix repo @@ -225,5 +211,7 @@ For static-only permissions (all booleans), dehydrate + hydrate + `setup` with t - Solid: https://github.com/letstri/permix/tree/main/examples/solid - Svelte: https://github.com/letstri/permix/tree/main/examples/svelte - Next.js (SSR): https://github.com/letstri/permix/tree/main/examples/next +- Nuxt (SSR): https://github.com/letstri/permix/tree/main/examples/nuxt +- React Router 7 (SSR): https://github.com/letstri/permix/tree/main/examples/react-router - Role templates: https://github.com/letstri/permix/tree/main/examples/role-based - ReBAC: https://github.com/letstri/permix/tree/main/examples/rebac diff --git a/permix/skills/permix/references/server.md b/permix/skills/permix/references/server.md index 8b8c79d5..cda00380 100644 --- a/permix/skills/permix/references/server.md +++ b/permix/skills/permix/references/server.md @@ -4,7 +4,7 @@ Authorization must run on the server. Client checks are UX only. Docs: https://permix.letstri.dev/docs/integrations/express -## Pattern (Express-style; similar for Hono, Fastify, Node) +## Pattern (Express-style; similar for Hono, Fastify, Node, Nest) Import from the framework subpath, not bare `permix`: @@ -59,6 +59,32 @@ app.delete( Denied requests default to `403` with `{ error: 'Forbidden' }`. Customize with `onForbidden` in `createPermix` options. +### NestJS (`permix/nest`) + +Use a global `APP_GUARD` plus `@Check`. The guard always sets up the per-request instance and only enforces a path when the decorator is present: + +```ts +import { APP_GUARD } from '@nestjs/core' +import { createPermix } from 'permix/nest' + +const permix = createPermix<{ + post: ['create', 'read'] +}>() + +{ + provide: APP_GUARD, + useValue: permix.guard(({ req }) => ({ + post: { create: !!req.user, read: true }, + })), +} + +@Get() +@permix.Check('post.read') +findAll() {} +``` + +Entity checks run in the handler after the resource is loaded: `permix.getOrThrow(req).check('post.update', post)`. + ### Access instance in handlers ```ts @@ -77,16 +103,19 @@ app.get('/posts/:id', (req, res) => { | Express | `permix/express` | | Hono | `permix/hono` | | Fastify | `permix/fastify` | +| NestJS | `permix/nest` | | tRPC | `permix/trpc` | | oRPC | `permix/orpc` | | Generic HTTP | `permix/node` or `permix/server` | +| Astro | `permix/astro` | | Elysia | `permix/elysia` | | Effect | `permix/effect` — see integration docs | -| Drizzle ORM | `permix/drizzle` (and `permix/drizzle/legacy`) — see integration docs | +| Drizzle ORM | `permix/drizzle` — see integration docs | +| Standard Schema | `permix/standard-schema` — Zod/Valibot entity types; see integration docs | Use the same `D` schema shape as the client instance. -Effect and Drizzle are optional peer dependencies; follow https://permix.letstri.dev/docs/integrations/effect and https://permix.letstri.dev/docs/integrations/drizzle rather than inventing middleware patterns. +Effect and Drizzle are optional peer dependencies; Standard Schema needs no extra Permix peer (install Zod/Valibot yourself). Follow https://permix.letstri.dev/docs/integrations/effect, https://permix.letstri.dev/docs/integrations/drizzle, and https://permix.letstri.dev/docs/integrations/standard-schema rather than inventing middleware patterns. ## tRPC / oRPC @@ -101,7 +130,7 @@ app.use(permix.setupMiddleware(rules)) ## Checklist -- [ ] `setupMiddleware` runs **before** `checkMiddleware` on protected routes +- [ ] `setupMiddleware` runs **before** `checkMiddleware` on protected routes (Nest: register `permix.guard(...)` as `APP_GUARD` before `@Check`) - [ ] Rules derived from authenticated `req.user` (or RPC context), not client headers alone - [ ] Entity checks pass resource data when the action has `type` / `required: true` - [ ] Same paths as frontend (`post.update`, not ad-hoc strings) diff --git a/permix/src/drizzle/legacy/index.ts b/permix/src/astro/index.ts similarity index 100% rename from permix/src/drizzle/legacy/index.ts rename to permix/src/astro/index.ts diff --git a/permix/src/astro/permix.test.ts b/permix/src/astro/permix.test.ts new file mode 100644 index 00000000..8ae85cd6 --- /dev/null +++ b/permix/src/astro/permix.test.ts @@ -0,0 +1,399 @@ +import { describe, expect, it, vi } from 'vitest' + +import type { ValidateDefinition } from '../core' +import { PermixNotFoundError } from '../core' +import { createPermix } from './permix' +import type { AstroContext } from './permix' + +interface Post { + id: string + authorId: string +} + +type PermissionsDefinition = ValidateDefinition<{ + post: ['create', 'read', 'update'] + user: ['delete'] +}> + +type PostWithData = ValidateDefinition<{ + post: [{ name: 'create'; type: Post }] +}> + +function createMockContext(): AstroContext { + return { + request: new Request('https://example.com'), + locals: {}, + } +} + +function createMockNext(response = new Response('ok')) { + return vi.fn(() => response) +} + +describe(createPermix, () => { + const permix = createPermix() + + it('should throw ts error', () => { + // @ts-expect-error path does not exist + permix.checkMiddleware('post.delete') + }) + + it('should allow access when permission is granted', async () => { + const context = createMockContext() + const next = createMockNext() + + await permix.setupMiddleware({ + post: { create: true, read: false, update: false }, + user: { delete: false }, + })(context, next) + + const result = await permix.checkMiddleware('post.create')(context, next) + + expect(result?.status).toBe(200) + expect(next).toHaveBeenCalledTimes(2) + expect(next).toHaveBeenLastCalledWith() + }) + + it('should deny access when permission is not granted', async () => { + const context = createMockContext() + const next = createMockNext() + + await permix.setupMiddleware({ + post: { create: false, read: false, update: false }, + user: { delete: false }, + })(context, next) + + const result = await permix.checkMiddleware('post.create')(context, next) + + expect(result?.status).toBe(403) + await expect(result?.text()).resolves.toBe( + JSON.stringify({ error: 'Forbidden' }) + ) + }) + + it('should work with custom error handler', async () => { + const permix = createPermix({ + onForbidden: () => + new Response(JSON.stringify({ error: 'Custom error' }), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }), + }) + + const context = createMockContext() + const next = createMockNext() + + await permix.setupMiddleware({ + post: { create: false, read: false, update: false }, + user: { delete: false }, + })(context, next) + + const result = await permix.checkMiddleware('post.create')(context, next) + + expect(result?.status).toBe(403) + await expect(result?.text()).resolves.toBe( + JSON.stringify({ error: 'Custom error' }) + ) + }) + + it('should work with custom error and params', async () => { + const permix = createPermix({ + onForbidden: ({ path }) => + new Response( + JSON.stringify({ error: `You do not have permission for ${path}` }), + { + status: 403, + headers: { 'Content-Type': 'application/json' }, + } + ), + }) + + const context = createMockContext() + const next = createMockNext() + + await permix.setupMiddleware({ + post: { create: false, read: false, update: false }, + user: { delete: false }, + })(context, next) + + const result = await permix.checkMiddleware('post.create')(context, next) + + expect(result?.status).toBe(403) + await expect(result?.text()).resolves.toBe( + JSON.stringify({ error: 'You do not have permission for post.create' }) + ) + }) + + it('should pass data through to a rule callback', async () => { + const permix = createPermix() + + const context = createMockContext() + const next = createMockNext() + + await permix.setupMiddleware({ + post: { + create: (post) => post?.authorId === '1', + }, + })(context, next) + + const result = await permix.checkMiddleware('post.create', { + id: 'a', + authorId: '1', + })(context, next) + + expect(result?.status).toBe(200) + }) + + it('should work with checker callback form', async () => { + const permix = createPermix() + + const context = createMockContext() + const next = createMockNext() + + await permix.setupMiddleware({ + post: { create: true, read: true, update: false }, + user: { delete: true }, + })(context, next) + + const result = await permix.checkMiddleware( + (c) => c('post.create') && c('user.delete') + )(context, next) + + expect(result?.status).toBe(200) + }) + + it('should work with template', async () => { + const template = permix.template({ + post: { create: true, read: true, update: true }, + user: { delete: true }, + }) + + const context = createMockContext() + const next = createMockNext() + + await permix.setupMiddleware(() => template())(context, next) + + const result = await permix.checkMiddleware('post.create')(context, next) + + expect(result?.status).toBe(200) + }) + + it('should dehydrate permissions', async () => { + const template = permix.template({ + post: { create: true, read: false, update: true }, + user: { delete: false }, + }) + + const context = createMockContext() + const next = createMockNext() + + await permix.setupMiddleware(() => template())(context, next) + + expect(permix.getOrThrow(context).dehydrate()).toStrictEqual({ + post: { create: true, read: false, update: true }, + user: { delete: false }, + }) + }) + + it('should read the instance from locals as well as context', async () => { + const context = createMockContext() + const next = createMockNext() + + await permix.setupMiddleware({ + post: { create: true, read: true, update: true }, + user: { delete: true }, + })(context, next) + + expect(permix.get(context.locals)?.check('post.create')).toBe(true) + expect(permix.getOrThrow(context.locals).check('post.create')).toBe(true) + }) + + it('should let two factories with different keys coexist on the same request', async () => { + const admin = createPermix().contextKey('admin') + const guest = createPermix().contextKey('guest') + + const context = createMockContext() + const next = createMockNext() + + await admin.setupMiddleware({ + post: { create: true, read: true, update: true }, + user: { delete: true }, + })(context, next) + await guest.setupMiddleware({ + post: { create: false, read: true, update: false }, + user: { delete: false }, + })(context, next) + + const adminNext = createMockNext() + const adminResult = await admin.checkMiddleware('post.create')( + context, + adminNext + ) + expect(adminResult?.status).toBe(200) + expect(adminNext).toHaveBeenCalledWith() + + const guestNext = createMockNext() + const guestResult = await guest.checkMiddleware('post.create')( + context, + guestNext + ) + expect(guestResult?.status).toBe(403) + expect(guestNext).not.toHaveBeenCalled() + }) + + it('should default to a per-instance symbol so two factories without a key do not collide', async () => { + const first = createPermix() + const second = createPermix() + + const context = createMockContext() + const next = createMockNext() + + await first.setupMiddleware({ + post: { create: true, read: true, update: true }, + user: { delete: true }, + })(context, next) + await second.setupMiddleware({ + post: { create: false, read: false, update: false }, + user: { delete: false }, + })(context, next) + + const firstNext = createMockNext() + const firstResult = await first.checkMiddleware('post.create')( + context, + firstNext + ) + expect(firstResult?.status).toBe(200) + + const secondNext = createMockNext() + const secondResult = await second.checkMiddleware('post.create')( + context, + secondNext + ) + expect(secondResult?.status).toBe(403) + }) + + it('should accept an explicit symbol key', async () => { + const key = Symbol('my-permix') + const permix = createPermix().contextKey(key) + + const context = createMockContext() + const next = createMockNext() + + await permix.setupMiddleware({ + post: { create: true, read: true, update: true }, + user: { delete: true }, + })(context, next) + + expect(Boolean((context.locals as Record)[key])).toBe( + true + ) + }) +}) + +describe('get / getOrThrow', () => { + const permix = createPermix() + + it('should return null when setupMiddleware has not run', () => { + const context = createMockContext() + expect(permix.get(context)).toBeNull() + }) + + it('should return the instance when setupMiddleware has run', async () => { + const context = createMockContext() + const next = createMockNext() + + await permix.setupMiddleware({ + post: { create: true, read: true, update: true }, + user: { delete: true }, + })(context, next) + + const p = permix.getOrThrow(context) + expect(p.check).toBeTypeOf('function') + }) + + it('getOrThrow should throw PermixNotFoundError when missing', () => { + const context = createMockContext() + expect(() => permix.getOrThrow(context)).toThrow(PermixNotFoundError) + }) +}) + +describe('checkMiddleware without setupMiddleware', () => { + it('should throw PermixNotFoundError', async () => { + const permix = createPermix() + + const context = createMockContext() + const next = createMockNext() + + await expect( + permix.checkMiddleware('post.create')(context, next) + ).rejects.toBeInstanceOf(PermixNotFoundError) + expect(next).not.toHaveBeenCalled() + }) +}) + +describe('onForbidden receives next', () => { + it('should allow onForbidden to throw custom errors', async () => { + const permix = createPermix({ + onForbidden: ({ path }) => { + throw new Error(`Forbidden: ${path}`) + }, + }) + + const context = createMockContext() + const next = createMockNext() + + await permix.setupMiddleware({ + post: { create: false, read: false, update: false }, + user: { delete: false }, + })(context, next) + + await expect( + permix.checkMiddleware('post.create')(context, next) + ).rejects.toMatchObject({ + message: 'Forbidden: post.create', + }) + }) +}) + +describe('Astro-style middleware composition', () => { + it('should compose setup and check middleware like sequence()', async () => { + const permix = createPermix() + + const middleware = [ + permix.setupMiddleware({ + post: { create: true, read: false, update: false }, + user: { delete: false }, + }), + permix.checkMiddleware('post.create'), + ] + + const run = async (context: AstroContext) => { + let index = 0 + const dispatch = async (): Promise => { + const handler = middleware[index++] + if (handler) { + return await handler(context, dispatch) + } + return Response.json({ ok: true }) + } + return await dispatch() + } + + const res = await run(createMockContext()) + expect(res.status).toBe(200) + await expect(res.json()).resolves.toStrictEqual({ ok: true }) + }) +}) + +describe('key exposure', () => { + it('should expose the key on the factory return', () => { + const permix = + createPermix().contextKey('custom-key') + expect(permix.key).toBe('custom-key') + }) + + it('should expose a symbol key when using default', () => { + const permix = createPermix() + expect(permix.key).toBeTypeOf('symbol') + }) +}) diff --git a/permix/src/astro/permix.ts b/permix/src/astro/permix.ts new file mode 100644 index 00000000..edb32e32 --- /dev/null +++ b/permix/src/astro/permix.ts @@ -0,0 +1,212 @@ +import type { Permix as PermixCore } from '../core' +import { + createHooks, + createPermix as createPermixCore, + createTemplate, + PermixNotFoundError, + withDenialReasons, +} from '../core' +import type { CheckArgs, CheckContext } from '../core/check' +import type { Definition } from '../core/definitions' +import type { PermixHooks, Rules, RulesPaths } from '../core/permix' +import type { MaybePromise } from '../utils' + +/** + * Minimal Astro `locals` bag. Compatible with `App.Locals`. + */ +export type AstroLocals = object + +/** + * Minimal Astro middleware / endpoint context. Compatible with `APIContext` + * from `astro`. + */ +export interface AstroContext { + request: Request + locals: AstroLocals +} + +export type MiddlewareNext = () => MaybePromise + +/** + * Astro middleware: `(context, next) => Response`. Compatible with + * `defineMiddleware` and `sequence` from `astro:middleware`. + */ +export type AstroMiddleware = ( + context: AstroContext, + next: MiddlewareNext +) => MaybePromise + +export interface MiddlewareContext { + context: AstroContext + request: Request + locals: AstroLocals + next: MiddlewareNext +} + +export interface PermixOptions { + /** + * Called when a `checkMiddleware` denies the request. Defaults to a 403 JSON + * response of `{ error: 'Forbidden' }`. + */ + onForbidden?: ( + params: CheckContext & MiddlewareContext + ) => MaybePromise +} + +function isContext(source: AstroContext | AstroLocals): source is AstroContext { + return ( + typeof source === 'object' && + source !== null && + 'locals' in source && + 'request' in source + ) +} + +function readLocals(source: AstroContext | AstroLocals): AstroLocals { + return isContext(source) ? source.locals : source +} + +function buildPermix( + resolveKey: () => string | symbol, + options: PermixOptions = {} +) { + const onForbidden = + options.onForbidden ?? + (() => + new Response(JSON.stringify({ error: 'Forbidden' }), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + })) + + const hooks = createHooks>() + + function get(source: AstroContext | AstroLocals): PermixCore | null { + const locals = readLocals(source) as Record + const instance = locals[resolveKey()] as PermixCore | undefined + return instance ?? null + } + + function getOrThrow(source: AstroContext | AstroLocals): PermixCore { + const instance = get(source) + if (!instance) { + throw new PermixNotFoundError(resolveKey()) + } + return instance + } + + function setupMiddleware( + callbackOrRules: + | ((context: MiddlewareContext) => MaybePromise>) + | Rules + ): AstroMiddleware { + return async (context, next) => { + const rules = + typeof callbackOrRules === 'function' + ? await callbackOrRules({ + context, + request: context.request, + locals: context.locals, + next, + }) + : callbackOrRules + const instance = createPermixCore().setup(rules) + instance.hook('check', (checkContext) => { + hooks.callHook('check', checkContext) + }) + const locals = context.locals as Record + locals[resolveKey()] = instance + return await next() + } + } + + const checkMiddleware: (...args: CheckArgs) => AstroMiddleware = + (...args) => + async (context, next) => { + const permix = get(context) + + if (!permix) { + throw new PermixNotFoundError(resolveKey()) + } + + const allowed = permix.check(...args) + + if (!allowed) { + return await onForbidden({ + context, + request: context.request, + locals: context.locals, + next, + ...withDenialReasons(permix, args), + }) + } + + return await next() + } + + function getRules(source: AstroContext | AstroLocals): Rules | null { + return get(source)?.getRules() ?? null + } + + function template(rules: Rules | ((param: T) => Rules)) { + return createTemplate(rules) + } + + return { + setupMiddleware, + checkMiddleware, + template, + get, + getOrThrow, + getRules, + hook: hooks.hook, + hookOnce: hooks.hookOnce, + get key() { + return resolveKey() + }, + $inferDefinition: undefined as unknown as D, + $inferPath: undefined as unknown as RulesPaths, + } +} + +/** + * Create a middleware factory that wires Permix into Astro. + * + * The instance is stored on `context.locals`, so middleware, endpoints, and + * server-rendered pages in the same request share one instance. + * + * @example + * ```ts + * // src/middleware.ts + * import { defineMiddleware } from 'astro:middleware' + * import { createPermix } from 'permix/astro' + * + * export const permix = createPermix<{ + * post: ['create', 'read', 'update', 'delete'] + * }>() + * + * export const onRequest = defineMiddleware( + * permix.setupMiddleware(({ request }) => ({ + * post: { create: true, read: true, update: false, delete: false }, + * })), + * ) + * ``` + * + * @link https://permix.letstri.dev/docs/integrations/astro + */ +export function createPermix( + options: PermixOptions = {} +) { + let key: string | symbol = Symbol('permix') + const permix = buildPermix(() => key, options) + + return Object.assign(permix, { + contextKey(newKey: string | symbol) { + key = newKey + return permix + }, + }) +} + +export type AstroPermix = ReturnType< + typeof createPermix +> diff --git a/permix/src/core/check.test.ts b/permix/src/core/check.test.ts new file mode 100644 index 00000000..d4bdccc4 --- /dev/null +++ b/permix/src/core/check.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' + +import { unwrapRuleResult } from './check' +import { PermixRuleNotDefinedError } from './errors' +import { createPermix } from './permix' + +describe('prototype-safe check walk', () => { + it('should throw for inherited Object.prototype names', () => { + const permix = createPermix<{ + post: ['create', 'read'] + }>().setup({ + post: { create: false, read: false }, + }) + + const check = permix.check as (path: string) => boolean + + expect(() => check('post.toString')).toThrow(PermixRuleNotDefinedError) + expect(() => check('post.valueOf')).toThrow(PermixRuleNotDefinedError) + expect(() => check('post.constructor')).toThrow(PermixRuleNotDefinedError) + expect(permix.check('post.create')).toBe(false) + }) +}) + +describe(unwrapRuleResult, () => { + it('keeps booleans as allowed with no reasons', () => { + expect(unwrapRuleResult(true)).toStrictEqual({ + allowed: true, + reasons: [], + }) + expect(unwrapRuleResult(false)).toStrictEqual({ + allowed: false, + reasons: [], + }) + }) + + it('keeps a denial reason and drops reasons on allow', () => { + expect(unwrapRuleResult({ allow: false, reason: 'nope' })).toStrictEqual({ + allowed: false, + reasons: ['nope'], + }) + expect(unwrapRuleResult({ allow: true, reason: 'ok' })).toStrictEqual({ + allowed: true, + reasons: [], + }) + }) +}) diff --git a/permix/src/core/check.ts b/permix/src/core/check.ts index cff802b9..0948f042 100644 --- a/permix/src/core/check.ts +++ b/permix/src/core/check.ts @@ -7,7 +7,7 @@ import type { SpecialPath, SpecialSymbol, } from './permix' -import type { Rules } from './rules' +import type { RuleDecision, RuleResult, Rules } from './rules' export type CheckArgs = | { @@ -16,7 +16,39 @@ export type CheckArgs = | [special: SpecialPath] | [callback: (c: CheckerFn) => boolean] -type Rule = Rules | boolean | ((data?: unknown) => boolean) +type Rule = Rules | boolean | ((data?: unknown) => RuleResult) + +export interface ExplainResult { + readonly allowed: boolean + readonly path: string | null + readonly reasons: readonly string[] +} + +interface WalkResult { + readonly allowed: boolean + readonly reasons: readonly string[] +} + +function isRuleDecision(value: unknown): value is RuleDecision { + if (typeof value !== 'object' || value === null || !('allow' in value)) { + return false + } + return typeof value.allow === 'boolean' +} + +export function unwrapRuleResult(value: unknown): WalkResult { + if (typeof value === 'boolean') { + return { allowed: value, reasons: [] } + } + if (isRuleDecision(value)) { + const reason = value.reason + if (value.allow || reason === undefined || reason.length === 0) { + return { allowed: value.allow, reasons: [] } + } + return { allowed: false, reasons: [reason] } + } + return { allowed: Boolean(value), reasons: [] } +} function isSpecialSymbol(value: unknown): value is SpecialSymbol { return value === '~any' || value === '~all' @@ -36,13 +68,20 @@ function pathFromArgs(args: unknown[]): string { */ export function callRuleWithoutData(rule: () => unknown): boolean { try { - return Boolean(rule()) + return unwrapRuleResult(rule()).allowed } catch { return false } } -function walk(rules: Rules, inputArgs: unknown[]): boolean { +function ownChild(parent: object, key: string): Rule | undefined { + if (!Object.hasOwn(parent, key)) { + return undefined + } + return (parent as Record)[key] +} + +function walk(rules: Rules, inputArgs: unknown[]): WalkResult { let args = inputArgs const first = args[0] @@ -51,10 +90,18 @@ function walk(rules: Rules, inputArgs: unknown[]): boolean { const last = parts.at(-1) if (isSpecialSymbol(last)) { - let subtree: Rule = rules + let subtree: Rule | undefined = rules for (let i = 0; i < parts.length - 1; i++) { + const segment = parts[i] + if (segment === undefined) { + subtree = undefined + break + } if (subtree && typeof subtree === 'object') { - subtree = (subtree as Record)[parts[i]] + subtree = ownChild(subtree, segment) + } else { + subtree = undefined + break } } @@ -63,20 +110,39 @@ function walk(rules: Rules, inputArgs: unknown[]): boolean { throw new PermixRuleNotDefinedError(path) } - const out: boolean[] = [] + const out: WalkResult[] = [] const visit = (rule: Rule) => { if (typeof rule === 'boolean') { - return void out.push(rule) + out.push({ allowed: rule, reasons: [] }) + return } if (typeof rule === 'function') { - return void out.push(callRuleWithoutData(rule)) + try { + out.push(unwrapRuleResult(rule())) + } catch { + out.push({ allowed: false, reasons: [] }) + } + return } - for (const key in rule) { - visit(rule[key]) + for (const key of Object.keys(rule)) { + const child = ownChild(rule, key) + if (child !== undefined) { + visit(child) + } } } visit(subtree) - return last === '~all' ? out.every(Boolean) : out.some(Boolean) + if (out.length === 0) { + return { allowed: false, reasons: [] } + } + const allowed = + last === '~all' + ? out.every((item) => item.allowed) + : out.some((item) => item.allowed) + const reasons = allowed + ? [] + : out.flatMap((item) => (item.allowed ? [] : item.reasons)) + return { allowed, reasons } } if (first.includes('.')) { @@ -84,10 +150,14 @@ function walk(rules: Rules, inputArgs: unknown[]): boolean { } } - let rule: Rule = rules + let rule: Rule | undefined = rules let i = 0 for (; i < args.length && typeof rule === 'object'; i++) { - rule = rule[String(args[i])] + const arg = args[i] + if (typeof arg !== 'string') { + break + } + rule = ownChild(rule, arg) } if (typeof rule === 'boolean') { @@ -95,37 +165,110 @@ function walk(rules: Rules, inputArgs: unknown[]): boolean { if (remainingPath) { throw new PermixRuleNotDefinedError(pathFromArgs(args)) } - return rule + return { allowed: rule, reasons: [] } } if (typeof rule === 'function') { - return Boolean(rule(args[i])) + return unwrapRuleResult(rule(args[i])) } const path = args.slice(0, i + 1).join('.') throw new PermixRuleNotDefinedError(path) } +function evaluate( + rules: Rules | null | (() => Rules | null), + args: CheckArgs +): WalkResult { + const r = typeof rules === 'function' ? rules() : rules + + if (!r) { + throw new PermixNotReadyError() + } + + if (typeof args[0] === 'function') { + const reasons: string[] = [] + const allowed = Boolean( + args[0]((path, ...data) => { + const result = walk(r, [path, ...data]) + if (!result.allowed) { + reasons.push(...result.reasons) + } + return result.allowed + }) + ) + return { allowed, reasons: allowed ? [] : reasons } + } + + return walk(r, args) +} + export function createCheck( rules: Rules | null | (() => Rules | null) ) { - return (...args: CheckArgs): boolean => { - const r = typeof rules === 'function' ? rules() : rules + return (...args: CheckArgs): boolean => evaluate(rules, args).allowed +} - if (!r) { - throw new PermixNotReadyError() +export function createExplain( + rules: Rules | null | (() => Rules | null) +) { + return (...args: CheckArgs): ExplainResult => { + const result = evaluate(rules, args) + const context = createCheckContext(...args) + return { + allowed: result.allowed, + path: context.path, + reasons: result.reasons, } + } +} - if (typeof args[0] === 'function') { - return Boolean(args[0]((path, ...data) => walk(r, [path, ...data]))) - } +/** + * Evaluate a check against overlay rules when the instance has none yet + * (hydrate first paint), otherwise go through `instance.check` so hooks and + * Standard Schema `validate` run. + */ +export function runCheck( + instance: { + check: (...args: any[]) => boolean + getRules: () => Rules | null + }, + overlay: Rules | null | undefined, + ...args: any[] +): boolean { + if (instance.getRules() === null && overlay) { + return (createCheck(overlay as never) as (...next: any[]) => boolean)( + ...args + ) + } + return instance.check(...args) +} - return walk(r, args) +/** + * Same overlay rule as {@link runCheck}: first-paint dehydrated rules must not + * throw {@link PermixNotReadyError}. Does not fire the `check` hook. + */ +export function runExplain( + instance: { + explain: (...args: any[]) => ExplainResult + getRules: () => Rules | null + }, + overlay: Rules | null | undefined, + ...args: any[] +): ExplainResult { + if (instance.getRules() === null && overlay) { + return ( + createExplain(overlay as never) as (...next: any[]) => ExplainResult + )(...args) } + return instance.explain(...args) } export interface CheckContext { path: RulesPaths | SpecialPath | null data?: unknown + allowed?: boolean + error?: unknown + reasons?: readonly string[] } export function createCheckContext( @@ -142,5 +285,22 @@ export function createCheckContext( return { path: first } } - return { path: first, data: params[1] } + const data = params[1] + if (data === undefined) { + return { path: first } + } + + return { path: first, data } +} + +export function withDenialReasons( + instance: { explain: (...args: CheckArgs) => ExplainResult }, + args: CheckArgs +): CheckContext { + const explanation = instance.explain(...args) + return { + ...createCheckContext(...args), + allowed: explanation.allowed, + reasons: explanation.reasons, + } } diff --git a/permix/src/core/definitions.ts b/permix/src/core/definitions.ts index 624960db..c6ba4fc6 100644 --- a/permix/src/core/definitions.ts +++ b/permix/src/core/definitions.ts @@ -1,11 +1,35 @@ +import type { + InferStandardSchemaOutput, + StandardSchemaV1, +} from './standard-schema' + export interface ActionSpec { name: string type?: unknown + /** + * A Standard Schema (Zod, Valibot, ArkType, Effect Schema, …). Entity data + * for this action is inferred as {@link InferStandardSchemaOutput}. + * Ignored when {@link type} is also set — `type` wins. + */ + schema?: StandardSchemaV1 required?: boolean } export type Action = string | ActionSpec +/** + * Entity data type for an action: {@link ActionSpec.type} if present, else + * {@link InferStandardSchemaOutput} of {@link ActionSpec.schema}. `never` + * when the action is a plain string (no entity data). + */ +export type ActionData = A extends string + ? never + : A extends { type: infer T } + ? T + : A extends { schema: infer S extends StandardSchemaV1 } + ? InferStandardSchemaOutput + : never + /** * The full type of a permissions tree passed to `createPermix()`. * diff --git a/permix/src/core/errors.ts b/permix/src/core/errors.ts index 19276dfd..41e95478 100644 --- a/permix/src/core/errors.ts +++ b/permix/src/core/errors.ts @@ -28,13 +28,22 @@ export class PermixNotFoundError extends PermixError { constructor(key?: string | symbol) { super('Instance not found. Please setup the permix instance first.') this.name = 'PermixNotFoundError' - this.key = key + if (key !== undefined) { + this.key = key + } } } export class PermixForbiddenError extends PermixError { - constructor() { + path?: string | null + reasons: string[] + + constructor(payload?: { path?: string | null; reasons?: readonly string[] }) { super('Forbidden.') this.name = 'PermixForbiddenError' + if (payload?.path !== undefined) { + this.path = payload.path + } + this.reasons = payload?.reasons === undefined ? [] : [...payload.reasons] } } diff --git a/permix/src/core/index.ts b/permix/src/core/index.ts index a0a49baf..1b1ecf24 100644 --- a/permix/src/core/index.ts +++ b/permix/src/core/index.ts @@ -3,6 +3,9 @@ export type * from './definitions' export * from './errors' export * from './hooks' export type * from './merge' +export * from './permission' +export * from './permission-overlay' export * from './permix' export * from './rules' +export * from './standard-schema' export * from './template' diff --git a/permix/src/core/permission-overlay.test.ts b/permix/src/core/permission-overlay.test.ts new file mode 100644 index 00000000..f85940be --- /dev/null +++ b/permix/src/core/permission-overlay.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, expectTypeOf, it } from 'vitest' + +import type { ActionData } from './definitions' +import { createPermissionOverlay } from './permission-overlay' +import type { + ApplyPermissionOverlay, + UnknownPermissionOverlayPaths, +} from './permission-overlay' +import type { RulesPaths } from './permix' + +const extractedDefinition = { + projects: ['read', 'update'], + workspace: { + members: ['invite'], + }, +} as const + +describe(createPermissionOverlay, () => { + it('preserves payload types for matching extracted permissions', () => { + const defineOverlay = createPermissionOverlay() + const overlay = defineOverlay({ + projects: [ + { + name: 'update', + type: {} as { projectId: string }, + }, + ], + }) + + type Definition = ApplyPermissionOverlay< + typeof extractedDefinition, + typeof overlay + > + + expect(overlay.projects[0]?.name).toBe('update') + expectTypeOf>().toEqualTypeOf< + 'projects.read' | 'projects.update' | 'workspace.members.invite' + >() + expectTypeOf>().toEqualTypeOf<{ + projectId: string + }>() + }) + + it('reports overlay paths that are absent from extracted source', () => { + type Unknown = UnknownPermissionOverlayPaths< + typeof extractedDefinition, + { + projects: ['delete'] + workspace: { + members: ['remove'] + } + } + > + + expectTypeOf().toEqualTypeOf< + 'projects.delete' | 'workspace.members.remove' + >() + + function invalidOverlayExample() { + const defineOverlay = + createPermissionOverlay() + + // @ts-expect-error Overlay paths must exist in the extracted definition. + defineOverlay({ projects: ['delete'] }) + } + + expectTypeOf(invalidOverlayExample).toBeFunction() + }) +}) diff --git a/permix/src/core/permission-overlay.ts b/permix/src/core/permission-overlay.ts new file mode 100644 index 00000000..c3b0a9f1 --- /dev/null +++ b/permix/src/core/permission-overlay.ts @@ -0,0 +1,71 @@ +import type { Action, ActionName, Definition } from './definitions' +import type { RulesPaths } from './permix' + +type ActionByName< + Actions extends readonly Action[], + Name extends string, +> = Actions[number] extends infer Candidate extends Action + ? Candidate extends unknown + ? ActionName extends Name + ? Candidate + : never + : never + : never + +type ApplyActionOverlay< + Base extends readonly Action[], + Overlay extends readonly Action[], +> = { + readonly [Index in keyof Base]: Base[Index] extends infer BaseAction extends + Action + ? ActionByName> extends infer OverlayAction + ? [OverlayAction] extends [never] + ? BaseAction + : OverlayAction + : never + : never +} + +export type ApplyPermissionOverlay< + Base extends Definition, + Overlay extends Definition, +> = Base extends readonly Action[] + ? Overlay extends readonly Action[] + ? ApplyActionOverlay + : Base + : Base extends { readonly [key: string]: Definition } + ? { + readonly [Key in keyof Base]: Key extends keyof Overlay + ? Overlay[Key] extends Definition + ? ApplyPermissionOverlay + : Base[Key] + : Base[Key] + } + : never + +export type UnknownPermissionOverlayPaths< + Base extends Definition, + Overlay extends Definition, +> = Exclude, RulesPaths> + +export type ValidatePermissionOverlay< + Base extends Definition, + Overlay extends Definition, +> = [UnknownPermissionOverlayPaths] extends [never] + ? unknown + : { + readonly __unknownPermissionPaths__: UnknownPermissionOverlayPaths< + Base, + Overlay + > + } + +/** + * Creates an identity helper that limits a typed payload overlay to extracted + * permission paths. + */ +export function createPermissionOverlay() { + return ( + overlay: Overlay & ValidatePermissionOverlay + ): Overlay => overlay +} diff --git a/permix/src/core/permission.test.ts b/permix/src/core/permission.test.ts new file mode 100644 index 00000000..e407d869 --- /dev/null +++ b/permix/src/core/permission.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, expectTypeOf, it } from 'vitest' + +import { createPermissionConfig, permission } from './permission' + +describe(permission, () => { + it('returns string keys unchanged and preserves their literal type', () => { + const key = permission('tasks.comment') + + expect(key).toBe('tasks.comment') + expectTypeOf(key).toEqualTypeOf<'tasks.comment'>() + }) + + it('returns object keys unchanged and accepts JSON-safe metadata', () => { + const key = permission({ + key: 'billing.refund', + title: 'Refund a payment', + description: 'Allows an operator to refund a settled payment.', + tags: ['billing', 'elevated'], + annotations: { + area: 'payments', + elevated: true, + surfaces: ['admin', 'api'], + risk: 3, + owner: null, + }, + }) + + expect(key).toBe('billing.refund') + expectTypeOf(key).toEqualTypeOf<'billing.refund'>() + }) +}) + +describe(createPermissionConfig, () => { + it('preserves metadata and limits keys to extracted permissions', () => { + const definePermissionConfig = createPermissionConfig< + 'projects.read' | 'projects.update' + >() + const config = definePermissionConfig({ + 'projects.read': { + title: 'Read projects', + }, + }) + + expect(config['projects.read'].title).toBe('Read projects') + expectTypeOf(config).toEqualTypeOf<{ + readonly 'projects.read': { + readonly title: 'Read projects' + } + }>() + + function invalidConfigExample() { + definePermissionConfig({ + // @ts-expect-error Unknown permissions cannot be centrally enriched. + 'projects.delete': { + title: 'Delete projects', + }, + }) + } + + expectTypeOf(invalidConfigExample).toBeFunction() + }) +}) diff --git a/permix/src/core/permission.ts b/permix/src/core/permission.ts new file mode 100644 index 00000000..ed3685c1 --- /dev/null +++ b/permix/src/core/permission.ts @@ -0,0 +1,46 @@ +export type JsonPrimitive = boolean | null | number | string + +export interface JsonObject { + readonly [key: string]: JsonValue +} + +export type JsonValue = JsonPrimitive | JsonObject | readonly JsonValue[] + +export interface PermissionMetadata { + readonly title?: string + readonly description?: string + readonly tags?: readonly string[] + readonly annotations?: JsonObject +} + +export interface PermissionMarker< + K extends string = string, +> extends PermissionMetadata { + readonly key: K +} + +type ExactPermissionMetadataConfig< + Key extends string, + Config extends Readonly>, +> = Config & Readonly, never>> + +/** + * Marks a permission for catalog extraction without changing its runtime value. + * + * Permission keys must be static string literals so the extractor can discover + * them without executing application code. + */ +export function permission( + keyOrMarker: K | PermissionMarker +): K { + return typeof keyOrMarker === 'string' ? keyOrMarker : keyOrMarker.key +} + +/** + * Creates an identity helper for centrally enriching extracted permissions. + */ +export function createPermissionConfig() { + return >>( + config: ExactPermissionMetadataConfig + ): Config => config +} diff --git a/permix/src/core/permix.test.ts b/permix/src/core/permix.test.ts index aef3d742..05b522b6 100644 --- a/permix/src/core/permix.test.ts +++ b/permix/src/core/permix.test.ts @@ -1,13 +1,15 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest' -import { PermixNotReadyError, PermixRuleNotDefinedError } from './errors' +import { + PermixForbiddenError, + PermixNotReadyError, + PermixRuleNotDefinedError, +} from './errors' import { createPermix } from './permix' describe(createPermix, () => { it('should init basic rules', () => { - const permix = createPermix<['read']>() - - permix.setup({ + const permix = createPermix<['read']>().setup({ read: true, }) @@ -21,9 +23,7 @@ describe(createPermix, () => { }) it('should throw if rule is not defined', () => { - const permix = createPermix<{ post: ['create', 'read'] }>() - - permix.setup({ + const permix = createPermix<{ post: ['create', 'read'] }>().setup({ post: { create: true, read: true, @@ -38,9 +38,7 @@ describe(createPermix, () => { }) it('should throw when check path is deeper than a boolean leaf (setup)', () => { - const permix = createPermix<{ post: ['create', 'read'] }>() - - permix.setup({ + const permix = createPermix<{ post: ['create', 'read'] }>().setup({ post: true, } as never) @@ -53,9 +51,7 @@ describe(createPermix, () => { }) it('should throw when check path is deeper than a boolean leaf (hydrate)', () => { - const permix = createPermix<{ post: ['create', 'read'] }>() - - permix.hydrate({ + const permix = createPermix<{ post: ['create', 'read'] }>().hydrate({ // @ts-expect-error true is not a valid rule post: true, }) @@ -69,17 +65,15 @@ describe(createPermix, () => { }) it('should allow check on a flat boolean leaf without extra path segments', () => { - const permix = createPermix<['read']>() - - permix.setup({ read: true }) + const permix = createPermix<['read']>().setup({ read: true }) expect(permix.check('read')).toBe(true) }) it('should tolerate explicit undefined data at runtime even though it is a type error', () => { - const permix = createPermix<{ post: ['create'] }>() - - permix.setup({ post: { create: true } }) + const permix = createPermix<{ post: ['create'] }>().setup({ + post: { create: true }, + }) // @ts-expect-error undefined is not a valid data argument expect(permix.check('post.create', undefined)).toBe(true) @@ -92,9 +86,7 @@ describe(createPermix, () => { const permix = createPermix<{ post: ['read', { name: 'create'; type: Post }] - }>() - - permix.setup({ + }>().setup({ post: { read: true, create: (post) => post?.authorId === '1', @@ -113,9 +105,7 @@ describe(createPermix, () => { const permix = createPermix<{ post: [{ name: 'create'; type: Post; required: true }] - }>() - - permix.setup({ + }>().setup({ post: { create: (post) => post.authorId === '1', }, @@ -130,28 +120,26 @@ describe(createPermix, () => { it('should work without a per-action data type', () => { const permix = createPermix<{ post: ['create'] - }>() - - permix.setup({ + }>().setup({ post: { create: true }, }) expect(permix.check('post.create')).toBe(true) }) - it('should work with enum-based permissions', () => { - enum PostAction { - Create = 'create', - Read = 'read', - Update = 'update', - Delete = 'delete', - } + it('should work with enum-like permissions', () => { + const PostAction = { + Create: 'create', + Read: 'read', + Update: 'update', + Delete: 'delete', + } as const - const permix = createPermix<{ - post: [PostAction] - }>() + type PostActionName = (typeof PostAction)[keyof typeof PostAction] - permix.setup({ + const permix = createPermix<{ + post: [PostActionName] + }>().setup({ post: { [PostAction.Create]: true, [PostAction.Read]: true, @@ -191,13 +179,15 @@ describe(createPermix, () => { }) it('should resolve permissions once setup runs asynchronously', async () => { - const permix = createPermix<{ + const factory = createPermix<{ post: ['create'] }>() - setTimeout(() => { - permix.setup({ post: { create: true } }) - }, 10) + const permix = await new Promise((resolve) => { + setTimeout(() => { + resolve(factory.setup({ post: { create: true } })) + }, 10) + }) await permix.isReadyAsync() expect(permix.check('post.create')).toBe(true) @@ -209,9 +199,7 @@ describe('check ~all / ~any', () => { const permix = createPermix<{ post: ['create', 'read'] comment: ['create', 'read', 'update'] - }>() - - permix.setup({ + }>().setup({ post: { create: true, read: true }, comment: { create: true, read: true, update: true }, }) @@ -223,9 +211,7 @@ describe('check ~all / ~any', () => { const permix = createPermix<{ post: ['create', 'read'] comment: ['create', 'read', 'update'] - }>() - - permix.setup({ + }>().setup({ post: { create: true, read: false }, comment: { create: true, read: true, update: true }, }) @@ -237,9 +223,7 @@ describe('check ~all / ~any', () => { const permix = createPermix<{ post: ['create', 'read'] comment: ['create', 'read', 'update'] - }>() - - permix.setup({ + }>().setup({ post: { create: false, read: false }, comment: { create: false, read: true, update: false }, }) @@ -251,9 +235,7 @@ describe('check ~all / ~any', () => { const permix = createPermix<{ post: ['create', 'read'] comment: ['create', 'read', 'update'] - }>() - - permix.setup({ + }>().setup({ post: { create: false, read: false }, comment: { create: false, read: false, update: false }, }) @@ -264,9 +246,7 @@ describe('check ~all / ~any', () => { it('should evaluate function rules with no data when aggregating', () => { const permix = createPermix<{ post: ['create', 'read'] - }>() - - permix.setup({ + }>().setup({ post: { create: () => true, read: () => false, @@ -281,9 +261,7 @@ describe('check ~all / ~any', () => { const permix = createPermix<{ post: ['create', 'read'] comment: ['create', 'read', 'update'] - }>() - - permix.setup({ + }>().setup({ post: { create: true, read: true }, comment: { create: false, read: true, update: false }, }) @@ -301,9 +279,7 @@ describe('check ~all / ~any', () => { customer: ['create', 'delete'] guest: ['read', 'write'] } - }>() - - permix.setup({ + }>().setup({ user: { write: true }, workspace: { customer: { create: true, delete: true }, @@ -321,9 +297,7 @@ describe('check ~all / ~any', () => { it('should resolve function rules when aggregating a nested subtree', () => { const permix = createPermix<{ post: ['create', 'read'] - }>() - - permix.setup({ + }>().setup({ post: { create: () => true, read: () => false, @@ -333,6 +307,37 @@ describe('check ~all / ~any', () => { expect(permix.check('post.~any')).toBe(true) expect(permix.check('post.~all')).toBe(false) }) + + it('should deny empty-subtree ~all instead of vacuously allowing', () => { + const permix = createPermix<{ + post: ['create'] + }>().setup({} as never) + expect(permix.check('~all')).toBe(false) + expect(permix.check('~any')).toBe(false) + }) + + it('should deny ~all on an empty nested subtree', () => { + const permix = createPermix<{ + post: ['create'] + }>().setup({ post: {} } as never) + expect(permix.check('post.~all')).toBe(false) + expect(permix.check('post.~any')).toBe(false) + }) + + it('should treat entity-required throws as deny under ~any / ~all', () => { + const permix = createPermix<{ + post: [{ name: 'edit'; type: { id: string }; required: true }] + }>().setup({ + post: { + edit: (data) => data.id === '1', + }, + }) + + expect(permix.check('~any')).toBe(false) + expect(permix.check('~all')).toBe(false) + expect(permix.check('post.~any')).toBe(false) + expect(permix.check('post.~all')).toBe(false) + }) }) describe('deep rules', () => { @@ -343,9 +348,7 @@ describe('deep rules', () => { customer: ['write'] guest: ['write'] } - }>() - - permix.setup({ + }>().setup({ user: { write: true, }, @@ -373,9 +376,7 @@ describe('deep rules', () => { customer: ['write'] guest: ['write'] } - }>() - - permix.setup({ + }>().setup({ user: { read: true, write: (data) => data.authorId === '1', @@ -406,9 +407,7 @@ describe('deep rules', () => { it('should hydrate permissions from JSON state', () => { const permix = createPermix<{ post: ['create', 'read'] - }>() - - permix.hydrate({ + }>().hydrate({ post: { create: true, read: false, @@ -423,9 +422,7 @@ describe('deep rules', () => { const permix = createPermix<{ post: ['create', 'read', 'update'] comment: ['write', 'delete'] - }>() - - permix.hydrate({ + }>().hydrate({ post: { create: true, read: true, @@ -446,9 +443,7 @@ describe('deep rules', () => { it('should dehydrate permissions to JSON state', () => { const permix = createPermix<{ post: ['create', 'read'] - }>() - - permix.setup({ + }>().setup({ post: { create: true, read: false, @@ -464,18 +459,16 @@ describe('deep rules', () => { }, }) - permix.hydrate(dehydratedState) + const hydrated = permix.hydrate(dehydratedState) - expect(permix.check('post.create')).toBe(true) - expect(permix.check('post.read')).toBe(false) + expect(hydrated.check('post.create')).toBe(true) + expect(hydrated.check('post.read')).toBe(false) }) it('should evaluate function rules when dehydrating', () => { const permix = createPermix<{ post: ['create', 'read'] - }>() - - permix.setup({ + }>().setup({ post: { create: () => true, read: () => false, @@ -485,17 +478,15 @@ describe('deep rules', () => { const dehydrated = permix.dehydrate() expect(dehydrated).toStrictEqual({ post: { create: true, read: false } }) - permix.hydrate(dehydrated) - expect(permix.check('post.create')).toBe(true) - expect(permix.check('post.read')).toBe(false) + const hydrated = permix.hydrate(dehydrated) + expect(hydrated.check('post.create')).toBe(true) + expect(hydrated.check('post.read')).toBe(false) }) it('should evaluate data rules without check data when dehydrating', () => { const permix = createPermix<{ user: [{ name: 'write'; type: { authorId: string }; required: true }] - }>() - - permix.setup({ + }>().setup({ user: { write: (data) => data.authorId === '1', }, @@ -515,9 +506,7 @@ describe('deep rules', () => { it('should transfer state from server to client instance', () => { const permixServer = createPermix<{ post: ['create', 'read'] - }>() - - permixServer.setup({ + }>().setup({ post: { create: true, read: false, @@ -527,9 +516,7 @@ describe('deep rules', () => { const dehydrated = permixServer.dehydrate() const permixClient = createPermix<{ post: ['create', 'read'] - }>() - - permixClient.hydrate(dehydrated) + }>().hydrate(dehydrated) expect(permixClient.check('post.create')).toBe(true) expect(permixClient.check('post.read')).toBe(false) @@ -541,9 +528,7 @@ describe('deep rules', () => { guest: ['write'] user: ['write2'] } - }>() - - permix.setup({ + }>().setup({ workspace: { guest: { write: false }, user: { write2: true }, @@ -561,12 +546,12 @@ describe('deep rules', () => { describe('template', () => { it('should define static permissions with template', () => { - const permix = createPermix<{ + const factory = createPermix<{ post: ['create', 'read'] comment: ['create', 'read', 'update'] }>() - const adminPermissions = permix.template({ + const adminPermissions = factory.template({ post: { create: true, read: true, @@ -583,28 +568,30 @@ describe('deep rules', () => { comment: { create: true, read: true, update: true }, }) - permix.setup(adminPermissions()) + const permix = factory.setup(adminPermissions()) expect(permix.check('post.create')).toBe(true) }) it('should work with dynamic template function', () => { - const permix = createPermix<{ + const factory = createPermix<{ post: ['create', 'read'] }>() - const rolePermissions = permix.template(({ role }: { role: string }) => ({ - post: { - create: role === 'admin', - read: true, - }, - })) + const rolePermissions = factory.template( + ({ role }: { role: string }) => ({ + post: { + create: role === 'admin', + read: true, + }, + }) + ) - permix.setup(rolePermissions({ role: 'admin' })) - expect(permix.check('post.create')).toBe(true) + const admin = factory.setup(rolePermissions({ role: 'admin' })) + expect(admin.check('post.create')).toBe(true) - permix.setup(rolePermissions({ role: 'viewer' })) - expect(permix.check('post.create')).toBe(false) - expect(permix.check('post.read')).toBe(true) + const viewer = factory.setup(rolePermissions({ role: 'viewer' })) + expect(viewer.check('post.create')).toBe(false) + expect(viewer.check('post.read')).toBe(true) }) }) @@ -637,7 +624,7 @@ describe('deep rules', () => { expect(fn).toHaveBeenCalledOnce() }) - it('should fire ready only on the first setup call', () => { + it('should fire ready on every setup with the new instance', () => { const permix = createPermix<{ post: ['create', 'read'] }>() @@ -648,28 +635,106 @@ describe('deep rules', () => { permix.setup({ post: { create: true, read: true } }) permix.setup({ post: { create: false, read: true } }) - expect(fn).toHaveBeenCalledOnce() + expect(fn).toHaveBeenCalledTimes(2) }) - it('should not become ready on hydrate, only on setup', () => { + it('should ignore reserved hydrate keys and keep own denies', () => { const permix = createPermix<{ post: ['create', 'read'] }>() + .setup({ + post: { create: false, read: false }, + }) + .hydrate( + JSON.parse( + '{"post":{"__proto__":{"create":true},"read":false}}' + ) as never + ) + + expect(() => permix.check('post.create')).toThrow( + PermixRuleNotDefinedError + ) + expect(permix.check('post.read')).toBe(false) + }) + + it('should not become ready on hydrate, only on setup', () => { + const factory = createPermix<{ + post: ['create', 'read'] + }>() const fn = vi.fn() - permix.hook('ready', fn) + factory.hook('ready', fn) - permix.hydrate({ post: { create: true, read: false } }) + const hydrated = factory.hydrate({ post: { create: true, read: false } }) expect(fn).toHaveBeenCalledTimes(0) - expect(permix.isReady()).toBe(false) + expect(hydrated.isReady()).toBe(false) + expect(factory.isReady()).toBe(false) // check still works with hydrated booleans even though not ready - expect(permix.check('post.create')).toBe(true) + expect(hydrated.check('post.create')).toBe(true) - permix.setup({ post: { create: true, read: false } }) + const ready = factory.setup({ post: { create: true, read: false } }) expect(fn).toHaveBeenCalledOnce() - expect(permix.isReady()).toBe(true) + expect(ready.isReady()).toBe(true) + }) + + it('does not fire the setup hook from hydrate', () => { + const factory = createPermix<{ + post: ['create'] + }>() + const setup = vi.fn() + factory.hook('setup', setup) + + factory.hydrate({ post: { create: true } }) + + expect(setup).not.toHaveBeenCalled() + }) + + it('installs dehydrated booleans without becoming ready', () => { + const factory = createPermix<{ + post: ['create'] + }>() + const setup = vi.fn() + factory.hook('setup', setup) + + const hydrated = factory.install({ + dehydrated: { post: { create: true } }, + }) + + expect(setup).not.toHaveBeenCalled() + expect(hydrated.isReady()).toBe(false) + expect(hydrated.check('post.create')).toBe(true) + }) + + it('installs function rules as a ready instance', () => { + const factory = createPermix<{ + post: ['create'] + }>() + const setup = vi.fn() + const ready = vi.fn() + factory.hook('setup', setup) + factory.hook('ready', ready) + + const instance = factory.install({ + dehydrated: { post: { create: false } }, + rules: { post: { create: true } }, + }) + + expect(setup).toHaveBeenCalledOnce() + expect(ready).toHaveBeenCalledOnce() + expect(instance.isReady()).toBe(true) + expect(instance.check('post.create')).toBe(true) + }) + + it('throws when install() has neither dehydrated nor rules', () => { + const factory = createPermix<{ + post: ['create'] + }>() + + expect(() => factory.install({})).toThrow( + /requires dehydrated and\/or rules/ + ) }) it('should return removal function from hook', () => { @@ -689,13 +754,14 @@ describe('deep rules', () => { }) it('should report isReady correctly', () => { - const permix = createPermix<{ + const factory = createPermix<{ post: ['create'] }>() - expect(permix.isReady()).toBe(false) - permix.setup({ post: { create: true } }) + expect(factory.isReady()).toBe(false) + const permix = factory.setup({ post: { create: true } }) expect(permix.isReady()).toBe(true) + expect(factory.isReady()).toBe(false) }) it('should call check hook with path and data', () => { @@ -704,37 +770,43 @@ describe('deep rules', () => { 'create', { name: 'edit'; type: { authorId: string }; required: true }, ] - }>() + }>().setup({ + post: { create: true, edit: (post) => post.authorId === '1' }, + }) const fn = vi.fn() permix.hook('check', fn) - permix.setup({ - post: { create: true, edit: (post) => post.authorId === '1' }, - }) - permix.check('post.create') - expect(fn).toHaveBeenCalledWith({ path: 'post.create' }) + expect(fn).toHaveBeenCalledWith({ + path: 'post.create', + allowed: true, + reasons: [], + }) permix.check('post.edit', { authorId: '1' }) expect(fn).toHaveBeenCalledWith({ path: 'post.edit', data: { authorId: '1' }, + allowed: true, + reasons: [], }) }) it('should call check hook with null path for callback form', () => { const permix = createPermix<{ post: ['create', 'read'] - }>() + }>().setup({ post: { create: true, read: false } }) const fn = vi.fn() permix.hook('check', fn) - permix.setup({ post: { create: true, read: false } }) - permix.check((c) => c('post.create') && c('post.read')) - expect(fn).toHaveBeenCalledWith({ path: null }) + expect(fn).toHaveBeenCalledWith({ + path: null, + allowed: false, + reasons: [], + }) }) it('should resolve isReadyAsync immediately if already ready', async () => { @@ -745,15 +817,148 @@ describe('deep rules', () => { await expect(permix.isReadyAsync()).resolves.toBeUndefined() }) - it('should resolve isReadyAsync once setup is called', async () => { - const permix = createPermix<{ + it('should resolve isReadyAsync on the instance returned from setup', async () => { + const factory = createPermix<{ post: ['create'] }>() - const promise = permix.isReadyAsync() - permix.setup({ post: { create: true } }) + const permix = factory.setup({ post: { create: true } }) + + await expect(permix.isReadyAsync()).resolves.toBeUndefined() + }) + }) +}) + +describe('explain', () => { + it('should keep check() as a boolean when a rule returns a decision', () => { + const permix = createPermix<{ + post: ['create'] + }>().setup({ + post: { + create: () => ({ allow: false, reason: 'not an author' }), + }, + }) + + expect(permix.check('post.create')).toBe(false) + }) + + it('should return the denial reason from a closure', () => { + const permix = createPermix<{ + post: ['create'] + }>().setup({ + post: { + create: () => ({ allow: false, reason: 'not an author' }), + }, + }) + + expect(permix.explain('post.create')).toStrictEqual({ + allowed: false, + path: 'post.create', + reasons: ['not an author'], + }) + }) + + it('should fire the check hook with reasons after eval', () => { + const permix = createPermix<{ + post: ['create'] + }>().setup({ + post: { + create: () => ({ allow: false, reason: 'not an author' }), + }, + }) + + const fn = vi.fn() + permix.hook('check', fn) + + expect(permix.check('post.create')).toBe(false) + expect(fn).toHaveBeenCalledWith({ + path: 'post.create', + allowed: false, + reasons: ['not an author'], + }) + }) + + it('should aggregate denial reasons for ~all', () => { + const permix = createPermix<{ + post: ['create', 'read', 'delete'] + }>().setup({ + post: { + create: () => ({ allow: false, reason: 'cannot create' }), + read: true, + delete: () => ({ allow: false, reason: 'cannot delete' }), + }, + }) + + expect(permix.check('post.~all')).toBe(false) + expect(permix.explain('post.~all')).toStrictEqual({ + allowed: false, + path: 'post.~all', + reasons: ['cannot create', 'cannot delete'], + }) + }) + + it('should dehydrate function decisions to booleans without reasons', () => { + const permix = createPermix<{ + post: ['create', 'read'] + }>().setup({ + post: { + create: () => ({ allow: true, reason: 'ok' }), + read: () => ({ allow: false, reason: 'nope' }), + }, + }) - await expect(promise).resolves.toBeUndefined() + expect(permix.dehydrate()).toStrictEqual({ + post: { create: true, read: false }, }) }) + + it('should attach path and reasons on PermixForbiddenError', () => { + const error = new PermixForbiddenError({ + path: 'post.create', + reasons: ['not an author'], + }) + + expect(error.path).toBe('post.create') + expect(error.reasons).toStrictEqual(['not an author']) + }) +}) + +describe('frozen rules', () => { + it('should ignore mutation of the object passed to setup', () => { + const rules = { + post: { create: false, read: false }, + } + const permix = createPermix<{ + post: ['create', 'read'] + }>().setup(rules) + rules.post.create = true + + expect(permix.check('post.create')).toBe(false) + }) + + it('should ignore mutation of getRules()', () => { + const permix = createPermix<{ + post: ['create', 'read'] + }>().setup({ + post: { create: false, read: false }, + }) + + const live = permix.getRules() + expect(live).not.toBeNull() + expect(() => { + live!.post.create = true + }).toThrow() + expect(permix.check('post.create')).toBe(false) + }) +}) + +describe('overlapping setup is isolated', () => { + it('does not leak overlapping setup() into an earlier instance', async () => { + const factory = createPermix<{ post: ['create'] }>() + const a = factory.setup({ post: { create: true } }) + const pending = Promise.resolve().then(() => a.check('post.create')) + factory.setup({ post: { create: false } }) + await expect(pending).resolves.toBe(true) + expect(a.check('post.create')).toBe(true) + }) }) diff --git a/permix/src/core/permix.ts b/permix/src/core/permix.ts index 6396a46d..3a37c59f 100644 --- a/permix/src/core/permix.ts +++ b/permix/src/core/permix.ts @@ -1,6 +1,6 @@ -import type { CheckArgs, CheckContext } from './check' -import { createCheck, createCheckContext } from './check' -import type { Action, ActionName, Definition } from './definitions' +import type { CheckArgs, CheckContext, ExplainResult } from './check' +import { createCheckContext, createExplain } from './check' +import type { Action, ActionData, ActionName, Definition } from './definitions' import { PermixNotReadyError } from './errors' import { createHooks } from './hooks' import type { DehydratedState, Rules } from './rules' @@ -9,11 +9,11 @@ import { createTemplate } from './template' export type { DehydratedState, Rules } from './rules' -type ActionArgs = A extends { type: infer T; required: true } - ? [T] - : A extends { type: infer T } - ? [T?] - : [] +type ActionArgs = [ActionData] extends [never] + ? [] + : A extends { required: true } + ? [ActionData] + : [ActionData?] type ActionByName = A extends unknown ? ActionName extends N @@ -87,29 +87,36 @@ export type CheckerFn =

>( ) => boolean export interface PermixHooks { - setup: () => void - ready: () => void + setup: (instance: Permix) => void + ready: (instance: Permix) => void check: (context: CheckContext) => void } +/** + * Options for {@link Permix.install}. At least one of `dehydrated` or + * `rules` is required. + */ +export interface InstallOptions { + dehydrated?: DehydratedState + rules?: Rules +} + export interface Permix { /** - * Provide the rules for this Permix instance. Must be called before `check()`. + * Build a frozen instance with these rules. Does not mutate `this`. * - * The rules object mirrors the shape of `D`: every leaf action becomes either - * a `boolean` or a `(data) => boolean` validator. + * Overlapping `setup()` calls on one factory return isolated instances — + * later calls cannot change an earlier instance's `check()`. * * @example * ```ts - * permix.setup({ - * post: { - * create: true, - * edit: (post) => post.authorId === currentUserId, - * }, + * const permix = createPermix<{ post: ['create'] }>().setup({ + * post: { create: true }, * }) + * permix.check('post.create') * ``` */ - setup: (rules: Rules) => void + setup: (rules: Rules) => Permix /** * Evaluate the current rules. Accepts one of three calling forms: @@ -140,6 +147,13 @@ export interface Permix { */ check: (...args: CheckArgs) => boolean + /** + * Same arguments as {@link Permix.check}, but returns `{ allowed, path, reasons }` + * so UIs can show why a path was denied. Boolean rules produce no reasons. + * Rule functions may `return { allow, reason }` instead of a bare boolean. + */ + explain: (...args: CheckArgs) => ExplainResult + /** * Serialize the current rules into a JSON-safe object. * @@ -158,20 +172,31 @@ export interface Permix { /** * Restore rules from a value produced by `dehydrate()`. * - * Hydration restores only the serialized booleans and **does not** mark the - * instance as ready: `isReady()` stays `false` and `isReadyAsync()` stays - * pending until `setup()` is called. This is deliberate — dehydrated state - * loses function-based rules, so you must re-run `setup()` on the client to - * fully restore them. Gating on readiness surfaces a forgotten `setup()` - * instead of silently serving collapsed permissions. + * Returns a **new** frozen instance. Hydration restores only the serialized + * booleans and **does not** mark that instance as ready: `isReady()` stays + * `false` until you `setup()` or `install({ rules })`. Boolean `check()` + * works on the returned instance (no `PermixNotReadyError`). Hydrate does + * **not** fire the `setup` hook — that is reserved for function rules. * * @example * ```ts - * permix.hydrate(serverState) // isReady() === false - * permix.setup(clientRules) // isReady() === true + * const client = permix.hydrate(serverState) // isReady() === false + * const ready = client.install({ rules: clientRules }) * ``` */ - hydrate: (state: DehydratedState) => void + hydrate: (state: DehydratedState) => Permix + + /** + * One-shot client entry: attach dehydrated booleans and/or function rules. + * + * - `{ dehydrated }` — same as {@link Permix.hydrate} (`isReady()` stays false) + * - `{ rules }` — same as {@link Permix.setup} (ready instance) + * - `{ dehydrated, rules }` — function rules win; hydrate (or a UI + * `PermixHydrate`) still supplies first-paint booleans + * + * Does not mutate `this`. + */ + install: (options: InstallOptions) => Permix /** * Define reusable permission rules separate from `setup()`. @@ -275,76 +300,89 @@ export interface Permix { } /** - * Create a type-safe Permix instance. - * - * @example Flat definition - * ```ts - * const permix = createPermix<['read', 'write']>() - * permix.setup({ read: true, write: false }) - * permix.check('read') // true - * ``` - * - * @example Nested definition - * ```ts - * const permix = createPermix<{ - * post: ['create', 'read'] - * user: ['invite'] - * }>() - * permix.setup({ - * post: { create: true, read: true }, - * user: { invite: false }, - * }) - * permix.check('post.create') // true - * permix.check('user.invite') // false - * ``` - * - * @example Per-action data types - * ```ts - * const permix = createPermix<{ - * post: [ - * 'create', - * 'read', - * { name: 'edit', type: { authorId: string }, required: true }, - * ] - * }>() - * permix.setup({ - * post: { - * create: true, - * read: true, - * edit: post => post.authorId === me.id, - * }, - * }) - * permix.check('post.create') // true - * permix.check('post.edit', { authorId: '1' }) // true/false - * ``` + * Factory returned by `createPermix()`. `setup()` / `hydrate()` return + * {@link Permix} instances; they never mutate the factory. */ -export function createPermix( - initialRules?: Rules +export type PermixFactory = Permix + +export type PermixInstance = Permix + +const checkEmitters = new WeakMap< + object, + (args: CheckArgs, allowed: boolean, error?: unknown) => void +>() + +const permixFamilies = new WeakMap() + +export function isSamePermixFamily(a: object, b: object): boolean { + return permixFamilies.get(a) === permixFamilies.get(b) +} + +/** + * Fire the `check` hook on an instance without evaluating rules. + * Used when Standard Schema `validate: 'deny'` rejects the payload. + */ +export function notifyCheck( + instance: object, + args: readonly unknown[], + allowed: boolean, + error?: unknown +): void { + checkEmitters.get(instance)?.(args as CheckArgs, allowed, error) +} + +interface PermixFamily { + lifecycle: ReturnType< + typeof createHooks, 'setup' | 'ready'>> + > +} + +function createFrozenPermix( + family: PermixFamily, + rules: Rules | null, + ready: boolean ): Permix { - let rules: Rules | null = initialRules ?? null - let ready = !!initialRules - const hooks = createHooks>() + const checkHooks = createHooks, 'check'>>() + const readyPromise = ready + ? Promise.resolve() + : new Promise(() => undefined) - const { promise: readyPromise, resolve: resolveReady } = ready - ? { promise: Promise.resolve(), resolve: () => undefined } - : Promise.withResolvers() + const explainFn = createExplain(() => rules) - const checkFn = createCheck(() => rules) + function emitCheck( + args: CheckArgs, + allowed: boolean, + error?: unknown, + reasons: readonly string[] = [] + ): void { + const context = createCheckContext(...args) + checkHooks.callHook( + 'check', + error === undefined + ? { ...context, allowed, reasons } + : { ...context, allowed, error, reasons } + ) + } - return { + const permix: Permix = { setup(r) { - rules = createRules(r) - hooks.callHook('setup') - if (!ready) { - ready = true - resolveReady() - hooks.callHook('ready') - } + const next = createFrozenPermix(family, createRules(r), true) + family.lifecycle.callHook('setup', next) + family.lifecycle.callHook('ready', next) + return next }, check(...args: CheckArgs): boolean { - const context = createCheckContext(...args) - hooks.callHook('check', context) - return checkFn(...args) + try { + const result = explainFn(...args) + emitCheck(args, result.allowed, undefined, result.reasons) + return result.allowed + } catch (error) { + emitCheck(args, false, error) + throw error + } + }, + explain(...args: CheckArgs): ExplainResult { + return explainFn(...args) }, dehydrate() { if (!rules) { @@ -353,18 +391,86 @@ export function createPermix( return dehydrateRules(rules) as DehydratedState }, hydrate(state) { - rules = hydrateRules(state) - hooks.callHook('setup') + return createFrozenPermix(family, hydrateRules(state), false) }, - template(rules) { - return createTemplate(rules) + install(options) { + if (options.rules !== undefined) { + const next = createFrozenPermix( + family, + createRules(options.rules), + true + ) + family.lifecycle.callHook('setup', next) + family.lifecycle.callHook('ready', next) + return next + } + if (options.dehydrated !== undefined) { + return createFrozenPermix( + family, + hydrateRules(options.dehydrated), + false + ) + } + throw new TypeError( + '[Permix]: install() requires dehydrated and/or rules' + ) + }, + template(templateRules) { + return createTemplate(templateRules) + }, + hook: (name, fn) => { + if (name === 'check') { + return checkHooks.hook('check', fn as PermixHooks['check']) + } + return family.lifecycle.hook(name, fn as never) + }, + hookOnce: (name, fn) => { + if (name === 'check') { + checkHooks.hookOnce('check', fn as PermixHooks['check']) + return + } + family.lifecycle.hookOnce(name, fn as never) }, - hook: hooks.hook, - hookOnce: hooks.hookOnce, isReady: () => ready, isReadyAsync: () => readyPromise, getRules: () => rules, $inferDefinition: undefined as unknown as D, $inferPath: undefined as unknown as RulesPaths, } + + checkEmitters.set(permix, (args, allowed, error) => { + emitCheck(args as CheckArgs, allowed, error) + }) + permixFamilies.set(permix, family) + + return Object.freeze(permix) +} + +/** + * Create a type-safe Permix factory. `setup(rules)`, `hydrate(state)`, and + * `install({ dehydrated, rules })` return frozen instances and never mutate + * the factory. + * + * Passing initial rules is the same as `createPermix().setup(rules)`. + * + * @example Flat definition + * ```ts + * const permix = createPermix<['read', 'write']>().setup({ + * read: true, + * write: false, + * }) + * permix.check('read') // true + * ``` + */ +export function createPermix( + initialRules?: Rules +): Permix { + const family: PermixFamily = { + lifecycle: createHooks, 'setup' | 'ready'>>(), + } + const factory = createFrozenPermix(family, null, false) + if (initialRules) { + return factory.setup(initialRules) + } + return factory } diff --git a/permix/src/core/rules.ts b/permix/src/core/rules.ts index 3ecbd78b..3a22218a 100644 --- a/permix/src/core/rules.ts +++ b/permix/src/core/rules.ts @@ -1,16 +1,27 @@ import { callRuleWithoutData } from './check' -import type { Action, ActionName, Definition } from './definitions' +import type { Action, ActionData, ActionName, Definition } from './definitions' -type ActionRule = A extends { type: infer T; required: true } - ? (data: T) => boolean - : A extends { type: infer T } - ? ((data?: T) => boolean) | boolean - : boolean | (() => boolean) +type ActionRule = [ActionData] extends [never] + ? boolean | (() => RuleResult) + : A extends { required: true } + ? (data: ActionData) => RuleResult + : ((data?: ActionData) => RuleResult) | boolean + +/** + * Structured result a rule function may return instead of a bare boolean. + * `check()` still collapses this to `boolean`; `explain()` keeps the reason. + */ +export interface RuleDecision { + readonly allow: boolean + readonly reason?: string +} + +export type RuleResult = boolean | RuleDecision /** * The shape of the object passed to `permix.setup()` (and produced by * {@link createRules}). It mirrors the `Definition` `D`: every leaf action - * becomes either a `boolean` or a `(data) => boolean` validator. + * becomes either a `boolean` or a `(data) => RuleResult` validator. */ export type Rules = D extends readonly Action[] ? { [E in D[number] as ActionName]: ActionRule } @@ -30,12 +41,35 @@ export type DehydratedState = D extends readonly Action[] } /** - * Recursively collapse a rules tree into its JSON-safe {@link DehydratedState}. - * - * Function-based rules are invoked once with no data; entity-required - * validators that throw on `undefined` are treated as `false`. + * Property names that must never become rule keys. Assigning `__proto__` on a + * plain object can install a prototype; `constructor` / `prototype` are the + * same class of inherited lookup. The extractor rejects these segments too. */ -export function dehydrateRules(node: unknown): unknown { +export const RESERVED_RULE_KEYS = new Set([ + '__proto__', + 'constructor', + 'prototype', +]) + +function cloneRuleNode(node: unknown, freeze: boolean): unknown { + if (typeof node === 'boolean' || typeof node === 'function') { + return node + } + if (node && typeof node === 'object') { + const source = node as Record + const result: Record = Object.create(null) + for (const key of Object.keys(source)) { + if (RESERVED_RULE_KEYS.has(key)) { + continue + } + result[key] = cloneRuleNode(source[key], freeze) + } + return freeze ? Object.freeze(result) : result + } + return node +} + +function dehydrateNode(node: unknown): unknown { if (typeof node === 'boolean') { return node } @@ -43,15 +77,41 @@ export function dehydrateRules(node: unknown): unknown { return callRuleWithoutData(node as () => unknown) } if (node && typeof node === 'object') { - const result: Record = {} - for (const key in node as Record) { - result[key] = dehydrateRules((node as Record)[key]) + const source = node as Record + const result: Record = Object.create(null) + for (const key of Object.keys(source)) { + if (RESERVED_RULE_KEYS.has(key)) { + continue + } + result[key] = dehydrateNode(source[key]) } return result } return node } +function toJsonObject(node: unknown): unknown { + if (!node || typeof node !== 'object') { + return node + } + const source = node as Record + const result: Record = {} + for (const key of Object.keys(source)) { + result[key] = toJsonObject(source[key]) + } + return result +} + +/** + * Recursively collapse a rules tree into its JSON-safe {@link DehydratedState}. + * + * Function-based rules are invoked once with no data; entity-required + * validators that throw on `undefined` are treated as `false`. + */ +export function dehydrateRules(node: unknown): unknown { + return toJsonObject(dehydrateNode(node)) +} + /** * Rebuild a {@link Rules} tree from a {@link DehydratedState} produced by * {@link dehydrateRules}. Only the serialized booleans are restored. @@ -59,22 +119,14 @@ export function dehydrateRules(node: unknown): unknown { export function hydrateRules( state: DehydratedState ): Rules { - const result: Record = {} - for (const key in state as Record) { - const value = (state as Record)[key] - result[key] = - typeof value === 'boolean' - ? value - : hydrateRules(value as DehydratedState) - } - return result as Rules + return cloneRuleNode(state, true) as Rules } /** * Build a typed {@link Rules} object for a given {@link Definition}. * - * Returns the input unchanged — useful for declaring rules in a separate - * location with full type inference. + * Copies the tree onto a null-prototype object and deep-freezes it so later + * mutation of the input (or of `getRules()`) cannot change authorization. * * @example * ```ts @@ -86,5 +138,5 @@ export function hydrateRules( * ``` */ export function createRules(rules: Rules): Rules { - return rules + return cloneRuleNode(rules, true) as Rules } diff --git a/permix/src/core/standard-schema.test.ts b/permix/src/core/standard-schema.test.ts new file mode 100644 index 00000000..731a2165 --- /dev/null +++ b/permix/src/core/standard-schema.test.ts @@ -0,0 +1,137 @@ +import * as v from 'valibot' +import { describe, expect, expectTypeOf, it } from 'vitest' +import { z } from 'zod' + +import { createPermix } from './permix' +import { action } from './standard-schema' + +const postSchema = z.object({ + id: z.string(), + authorId: z.string(), +}) + +const valibotPostSchema = v.object({ + id: v.string(), + authorId: v.string(), +}) + +describe('ActionSpec.schema', () => { + it('infers entity data from a Zod schema on the action spec', () => { + const permix = createPermix<{ + post: [ + 'create', + { name: 'edit'; schema: typeof postSchema; required: true }, + ] + }>().setup({ + post: { + create: true, + edit: (post) => { + expectTypeOf(post).toEqualTypeOf<{ id: string; authorId: string }>() + return post.authorId === '1' + }, + }, + }) + + expectTypeOf(permix.check) + .parameter(0) + .extract<'post.edit'>() + .toEqualTypeOf<'post.edit'>() + + expect(permix.check('post.edit', { id: 'p1', authorId: '1' })).toBe(true) + expect(permix.check('post.edit', { id: 'p1', authorId: '2' })).toBe(false) + // @ts-expect-error data is required + expect(() => permix.check('post.edit')).toThrow() + }) + + it('lets type override schema when both are set', () => { + const permix = createPermix<{ + post: [ + { + name: 'edit' + schema: typeof postSchema + type: { ownerId: string } + required: true + }, + ] + }>().setup({ + post: { + edit: (post) => post.ownerId === '1', + }, + }) + + expect(permix.check('post.edit', { ownerId: '1' })).toBe(true) + expect(permix.check('post.edit', { ownerId: '2' })).toBe(false) + }) + + it('does not parse check data at runtime', () => { + const permix = createPermix<{ + post: [{ name: 'edit'; schema: typeof postSchema }] + }>().setup({ + post: { + edit: (post) => post?.authorId === '1', + }, + }) + + expect( + permix.check('post.edit', { + id: 'p1', + authorId: '1', + extra: true, + } as { id: string; authorId: string }) + ).toBe(true) + }) +}) + +describe('action()', () => { + it('builds a definition whose schema types flow into check and setup', () => { + const definition = { + post: ['create', action('edit', postSchema, { required: true })], + } as const + + const permix = createPermix().setup({ + post: { + create: true, + edit: (post) => post.authorId === '1', + }, + }) + + expect(permix.check('post.create')).toBe(true) + expect(permix.check('post.edit', { id: 'p1', authorId: '1' })).toBe(true) + expect(permix.check('post.edit', { id: 'p1', authorId: '2' })).toBe(false) + // @ts-expect-error data is required + expect(() => permix.check('post.edit')).toThrow() + }) + + it('keeps check data optional when required is omitted', () => { + const definition = { + post: [action('edit', postSchema)], + } as const + + const permix = createPermix().setup({ + post: { + edit: (post) => post?.authorId === '1', + }, + }) + + expect(permix.check('post.edit')).toBe(false) + expect(permix.check('post.edit', { id: 'p1', authorId: '1' })).toBe(true) + }) +}) + +describe('ActionSpec.schema with Valibot', () => { + it('infers entity data from a Valibot schema', () => { + const permix = createPermix<{ + post: [{ name: 'edit'; schema: typeof valibotPostSchema; required: true }] + }>().setup({ + post: { + edit: (post) => { + expectTypeOf(post).toEqualTypeOf<{ id: string; authorId: string }>() + return post.authorId === '1' + }, + }, + }) + + expect(permix.check('post.edit', { id: 'p1', authorId: '1' })).toBe(true) + expect(permix.check('post.edit', { id: 'p1', authorId: '2' })).toBe(false) + }) +}) diff --git a/permix/src/core/standard-schema.ts b/permix/src/core/standard-schema.ts new file mode 100644 index 00000000..fe9e82a4 --- /dev/null +++ b/permix/src/core/standard-schema.ts @@ -0,0 +1,103 @@ +/** + * Standard Schema V1 types, vendored from + * {@link https://standardschema.dev | standardschema.dev} so Permix can infer + * entity data from Zod, Valibot, ArkType, Effect Schema, and other + * Standard Schema implementations without taking a runtime dependency. + * + * Flattened from the spec's `StandardSchemaV1` namespace to match this repo's + * lint rules. Core `createPermix()` uses these types for inference only. + * Runtime `validate` lives on `permix/standard-schema`. + */ + +export interface StandardSchemaV1Types { + readonly input: Input + readonly output: Output +} + +export interface StandardSchemaV1PathSegment { + readonly key: PropertyKey +} + +export interface StandardSchemaV1Issue { + readonly message: string + readonly path?: + | readonly (PropertyKey | StandardSchemaV1PathSegment)[] + | undefined +} + +export interface StandardSchemaV1SuccessResult { + readonly value: Output + readonly issues?: undefined +} + +export interface StandardSchemaV1FailureResult { + readonly issues: readonly StandardSchemaV1Issue[] +} + +export type StandardSchemaV1Result = + | StandardSchemaV1SuccessResult + | StandardSchemaV1FailureResult + +export interface StandardSchemaV1Props { + readonly version: 1 + readonly vendor: string + readonly validate: ( + value: unknown + ) => StandardSchemaV1Result | Promise> + readonly types?: StandardSchemaV1Types | undefined +} + +/** The Standard Schema interface. */ +export interface StandardSchemaV1 { + readonly '~standard': StandardSchemaV1Props +} + +/** Infers the input type of a Standard Schema. */ +export type InferStandardSchemaInput = + NonNullable['input'] + +/** Infers the output type of a Standard Schema. */ +export type InferStandardSchemaOutput = + NonNullable['output'] + +export interface ActionSchemaOptions { + required?: R +} + +/** + * Build an {@link import('./definitions').ActionSpec} whose entity data type + * is inferred from a Standard Schema (Zod, Valibot, ArkType, …). + * + * Use with `createPermix()` and `as const` so action names + * stay literal. + * + * @example + * ```ts + * const postSchema = z.object({ id: z.string(), authorId: z.string() }) + * + * const definition = { + * post: [ + * 'create', + * action('edit', postSchema, { required: true }), + * ], + * } as const + * + * const permix = createPermix() + * ``` + */ +export function action< + const N extends string, + S extends StandardSchemaV1, + const R extends boolean = false, +>( + name: N, + schema: S, + options?: ActionSchemaOptions +): R extends true + ? { readonly name: N; readonly schema: S; readonly required: true } + : { readonly name: N; readonly schema: S } { + if (options?.required === true) { + return { name, schema, required: true as const } as never + } + return { name, schema } as never +} diff --git a/permix/src/drizzle/legacy/permix.test.ts b/permix/src/drizzle/legacy/permix.test.ts deleted file mode 100644 index a9f03e2b..00000000 --- a/permix/src/drizzle/legacy/permix.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -// `relations` lives under `drizzle-orm/_relations` in drizzle v1, but it still -// produces the same "non-Table object" used here purely to verify filtering. -import { relations } from 'drizzle-orm/_relations' -import { integer, pgTable, serial, text } from 'drizzle-orm/pg-core' -import { describe, expect, expectTypeOf, it } from 'vitest' - -import { PermixRuleNotDefinedError } from '../../core/errors' -import { createPermix } from './permix' - -const users = pgTable('users', { - id: serial('id').primaryKey(), - name: text('name').notNull(), -}) - -const posts = pgTable('posts', { - id: serial('id').primaryKey(), - title: text('title').notNull(), - authorId: integer('author_id') - .notNull() - .references(() => users.id), -}) - -const usersRelations = relations(users, ({ many }) => ({ - posts: many(posts), -})) - -const schema = { users, posts, usersRelations } - -describe('drizzle createPermix', () => { - it('discovers only tables from the schema and ignores non-table exports', () => { - const permix = createPermix(schema) - - expect(permix.tables).toStrictEqual(['users', 'posts']) - expect(permix.actions).toStrictEqual(['create', 'read', 'update', 'delete']) - }) - - it('produces a Permix instance that accepts rules for every detected table', () => { - const permix = createPermix(schema) - - permix.setup({ - users: { create: true, read: true, update: false, delete: false }, - posts: { create: true, read: true, update: true, delete: false }, - }) - - expect(permix.check('users.read')).toBe(true) - expect(permix.check('users.delete')).toBe(false) - expect(permix.check('posts.update')).toBe(true) - expect(permix.check('posts.delete')).toBe(false) - }) - - it('infers leaf paths only for tables — relations are not part of the type', () => { - const permix = createPermix(schema) - - permix.setup({ - users: { create: true, read: true, update: false, delete: false }, - posts: { create: true, read: true, update: true, delete: false }, - }) - - // @ts-expect-error usersRelations is not a table, so it has no permissions - expect(() => permix.check('usersRelations.read')).toThrow( - PermixRuleNotDefinedError - ) - }) - - it('exposes correct types for tables and actions', () => { - const permix = createPermix(schema) - - expectTypeOf(permix.tables).toEqualTypeOf<('users' | 'posts')[]>() - expectTypeOf(permix.actions).toEqualTypeOf< - readonly ('create' | 'read' | 'update' | 'delete')[] - >() - }) -}) diff --git a/permix/src/drizzle/legacy/permix.ts b/permix/src/drizzle/legacy/permix.ts deleted file mode 100644 index b4467043..00000000 --- a/permix/src/drizzle/legacy/permix.ts +++ /dev/null @@ -1,130 +0,0 @@ -import type { Table } from 'drizzle-orm' -import { Table as DrizzleTable, is } from 'drizzle-orm' - -import type { Permix as PermixCore } from '../../core' -import { createPermix as createPermixCore } from '../../core' -import { PermixInvalidActionsError } from '../errors' - -/** - * The default CRUD action set used when no `actions` are provided. - */ -export const DEFAULT_DRIZZLE_ACTIONS = [ - 'create', - 'read', - 'update', - 'delete', -] as const - -export type DefaultDrizzleAction = (typeof DEFAULT_DRIZZLE_ACTIONS)[number] - -/** - * Keys of `S` whose values are Drizzle `Table` instances. - * Relations, enums, helpers, and other non-table exports are filtered out. - */ -export type DrizzleTableKeys = { - [K in keyof S]: S[K] extends Table ? K & string : never -}[keyof S] - -/** - * Permix {@link Definition} derived from a Drizzle schema, assigning the same - * `actions` tuple to every table. - */ -export type DrizzleDefinition< - S, - Actions extends readonly string[] = readonly DefaultDrizzleAction[], -> = { - [K in DrizzleTableKeys]: [...Actions] -} - -export type ActionMap = Partial< - Record -> - -export interface CreateDrizzlePermixOptions { - /** - * Override the actions generated for every table. Pass an `as const` tuple - * to preserve literal types. - * - * @default ['create', 'read', 'update', 'delete'] - */ - actions?: Actions -} - -/** - * Extends the core Permix API with Drizzle-specific metadata. - */ -export interface DrizzlePermix< - S, - Actions extends readonly string[], -> extends PermixCore> { - /** - * The list of action names that were generated for every table. - */ - readonly actions: Actions - - /** - * The list of table names that were detected in the supplied schema. - */ - readonly tables: DrizzleTableKeys[] -} - -/** - * Create a type-safe Permix instance whose permission tree mirrors a Drizzle - * schema. Every exported table becomes a top-level entity with the same set - * of actions (CRUD by default). - * - * Non-table exports such as `relations`, enums, helpers, etc. are skipped at - * both the type and runtime layers, so you can pass `import * as schema` as-is. - * - * @example - * ```ts - * import * as schema from './schema' - * import { createPermix } from 'permix/drizzle' - * - * const permix = createPermix(schema) - * - * permix.setup({ - * users: { create: true, read: true, update: false, delete: false }, - * posts: { create: true, read: true, update: true, delete: false }, - * }) - * - * permix.check('users.read') // true - * ``` - * - * @example Customising actions - * ```ts - * const permix = createPermix(schema, { - * actions: ['view', 'edit'] as const, - * }) - * ``` - */ -export function createPermix< - S extends Record, - const Actions extends readonly string[] = readonly DefaultDrizzleAction[], ->( - schema: S, - options: CreateDrizzlePermixOptions = {} -): DrizzlePermix { - const actions = (options.actions ?? - DEFAULT_DRIZZLE_ACTIONS) as unknown as Actions - - if (!Array.isArray(actions) || actions.length === 0) { - throw new PermixInvalidActionsError() - } - - const tables = Object.entries(schema) - .filter(([, value]) => is(value as object, DrizzleTable)) - .map(([key]) => key) as DrizzleTableKeys[] - - type D = DrizzleDefinition - - const permix = createPermixCore() - - return Object.assign(permix, { actions, tables }) -} - -/** Return type of {@link createPermix}. */ -export type DrizzlePermixInstance< - S extends Record, - Actions extends readonly string[] = readonly DefaultDrizzleAction[], -> = ReturnType> diff --git a/permix/src/drizzle/permix.test.ts b/permix/src/drizzle/permix.test.ts index 354c9fd2..90030a8e 100644 --- a/permix/src/drizzle/permix.test.ts +++ b/permix/src/drizzle/permix.test.ts @@ -38,9 +38,7 @@ describe('drizzle createPermix', () => { }) it('accepts rules for every detected entity (tables + views)', () => { - const permix = createPermix(schema) - - permix.setup({ + const permix = createPermix(schema).setup({ users: { create: true, read: true, update: false, delete: false }, posts: { create: true, read: true, update: true, delete: false }, activeUsers: { create: false, read: true, update: false, delete: false }, @@ -54,9 +52,7 @@ describe('drizzle createPermix', () => { }) it('infers leaf paths only for schema entries — relations are not part of the type', () => { - const permix = createPermix(schema) - - permix.setup({ + const permix = createPermix(schema).setup({ users: { create: true, read: true, update: false, delete: false }, posts: { create: true, read: true, update: true, delete: false }, activeUsers: { create: false, read: true, update: false, delete: false }, @@ -73,18 +69,16 @@ describe('drizzle createPermix', () => { expect(permix.actions).toStrictEqual(['view', 'edit']) - permix.setup({ + const ready = permix.setup({ users: { view: true, edit: false }, posts: { view: true, edit: true }, activeUsers: { view: true, edit: false }, }) - expect(permix.check('users.view')).toBe(true) - expect(permix.check('posts.edit')).toBe(true) + expect(ready.check('users.view')).toBe(true) + expect(ready.check('posts.edit')).toBe(true) // @ts-expect-error 'create' is not in the custom action set - expect(() => permix.check('users.create')).toThrow( - PermixRuleNotDefinedError - ) + expect(() => ready.check('users.create')).toThrow(PermixRuleNotDefinedError) }) it('throws when actions is an empty array', () => { diff --git a/permix/src/drizzle/permix.ts b/permix/src/drizzle/permix.ts index 8e6f9ebf..24f1a1ab 100644 --- a/permix/src/drizzle/permix.ts +++ b/permix/src/drizzle/permix.ts @@ -76,8 +76,7 @@ export interface DrizzlePermix< * `defineRelations(...)` objects, plain helpers, and other non-entity exports * are skipped automatically — pass `import * as schema` as-is. * - * If you're still on Drizzle v0 (`drizzle-orm@<1`), import from - * `permix/drizzle/legacy` instead. + * Requires Drizzle ORM v1 (`extractTablesFromSchema`). * * @example * ```ts @@ -121,9 +120,24 @@ export function createPermix< type D = DrizzleDefinition - const permix = createPermixCore() + function decorate(instance: PermixCore): DrizzlePermix { + return { + ...instance, + actions, + tables, + setup(rules) { + return decorate(instance.setup(rules)) + }, + hydrate(state) { + return decorate(instance.hydrate(state)) + }, + install(options) { + return decorate(instance.install(options)) + }, + } + } - return Object.assign(permix, { actions, tables }) + return decorate(createPermixCore()) } /** Return type of {@link createPermix}. */ diff --git a/permix/src/effect/permix.test.ts b/permix/src/effect/permix.test.ts index ed2435f2..53ffc166 100644 --- a/permix/src/effect/permix.test.ts +++ b/permix/src/effect/permix.test.ts @@ -273,13 +273,17 @@ describe(createPermix, () => { const program = Effect.gen(function* program() { const before = yield* permix.isReady() - yield* permix.setup({ + const instance = yield* permix.setup({ post: { create: true, read: true, update: false }, user: { delete: false }, }) - const after = yield* permix.isReady() - const canCreate = yield* permix.check('post.create') + const { after, canCreate } = yield* Effect.gen(function* inner() { + return { + after: yield* permix.isReady(), + canCreate: yield* permix.check('post.create'), + } + }).pipe(Effect.provideService(permix.Tag, instance)) return { before, after, canCreate } }) @@ -299,15 +303,17 @@ describe(createPermix, () => { const permix = createPermix() const program = Effect.gen(function* program() { - yield* permix.hydrate({ + const instance = yield* permix.hydrate({ post: { create: true, read: false, update: true }, user: { delete: false }, }) - return { - canCreate: yield* permix.check('post.create'), - canRead: yield* permix.check('post.read'), - } + return yield* Effect.gen(function* inner() { + return { + canCreate: yield* permix.check('post.create'), + canRead: yield* permix.check('post.read'), + } + }).pipe(Effect.provideService(permix.Tag, instance)) }) const result = await Effect.runPromise( @@ -327,8 +333,10 @@ describe(createPermix, () => { const program = Effect.gen(function* program() { const empty = yield* permix.getRules() - yield* permix.setup(rules) - const current = yield* permix.getRules() + const instance = yield* permix.setup(rules) + const current = yield* permix + .getRules() + .pipe(Effect.provideService(permix.Tag, instance)) return { empty, current } }) @@ -337,19 +345,25 @@ describe(createPermix, () => { ) expect(result.empty).toBeNull() - expect(result.current).toStrictEqual(rules) + // Frozen rules use a null prototype, so toStrictEqual would fail on constructor. + // oxlint-disable-next-line vitest/prefer-strict-equal + expect(result.current).toEqual(rules) }) it('should resolve isReadyAsync once setup runs', async () => { const permix = createPermix() const program = Effect.gen(function* program() { - yield* permix.setup({ + const instance = yield* permix.setup({ post: { create: true, read: true, update: true }, user: { delete: true }, }) - yield* permix.isReadyAsync() - return yield* permix.check('post.create') + yield* permix + .isReadyAsync() + .pipe(Effect.provideService(permix.Tag, instance)) + return yield* permix + .check('post.create') + .pipe(Effect.provideService(permix.Tag, instance)) }) const result = await Effect.runPromise( @@ -359,10 +373,10 @@ describe(createPermix, () => { expect(result).toBe(true) }) - it('should type the error channel of hydrate as PermixNotReadyError', () => { + it('should return a hydrated instance that is not ready', async () => { const permix = createPermix() - const flipped = Effect.flip( + const instance = await Effect.runPromise( permix .hydrate({ post: { create: true, read: false, update: false }, @@ -371,9 +385,9 @@ describe(createPermix, () => { .pipe(Effect.provide(permix.layer())) ) - expectTypeOf(flipped).toMatchTypeOf< - Effect.Effect - >() + expect(instance.isReady()).toBe(false) + expect(instance.check('post.create')).toBe(true) + expectTypeOf(instance.check).returns.toEqualTypeOf() }) it('should surface PermixNotReadyError when dehydrate is called before setup', async () => { diff --git a/permix/src/effect/permix.ts b/permix/src/effect/permix.ts index 50ba56f6..dfcb3fed 100644 --- a/permix/src/effect/permix.ts +++ b/permix/src/effect/permix.ts @@ -38,20 +38,22 @@ export function createPermix( const Tag = Context.GenericTag>(id) function layer(rules?: Rules) { - return Layer.sync(Tag, () => createPermixCore(rules)) + return Layer.sync(Tag, () => + rules === undefined + ? createPermixCore() + : createPermixCore().setup(rules) + ) } function layerSetup(rules: Effect.Effect, E, R>) { return Layer.effect( Tag, - Effect.map(rules, (r) => createPermixCore(r)) + Effect.map(rules, (r) => createPermixCore().setup(r)) ) } function setup(rules: Rules) { - return Effect.map(Tag, (instance) => { - instance.setup(rules) - }) + return Effect.map(Tag, (instance) => instance.setup(rules)) } function check(...args: CheckArgs) { @@ -73,14 +75,14 @@ export function createPermix( } function hydrate(state: DehydratedState) { - return Effect.flatMap(Tag, (instance) => - Effect.try({ - try: () => { - instance.hydrate(state) - }, - catch: (e) => e as PermixNotReadyError, - }) - ) + return Effect.map(Tag, (instance) => instance.hydrate(state)) + } + + function install(options: { + dehydrated?: DehydratedState + rules?: Rules + }) { + return Effect.map(Tag, (instance) => instance.install(options)) } function isReady() { @@ -125,6 +127,7 @@ export function createPermix( check, dehydrate, hydrate, + install, isReady, isReadyAsync, getRules, diff --git a/permix/src/elysia/permix.test.ts b/permix/src/elysia/permix.test.ts index 10088806..18daf2e2 100644 --- a/permix/src/elysia/permix.test.ts +++ b/permix/src/elysia/permix.test.ts @@ -322,6 +322,31 @@ describe('checkMiddleware without setupMiddleware', () => { }) }) +describe('fail closed after deny', () => { + it('should not run the route body when onForbidden is a no-op', async () => { + const permix = createPermix({ + onForbidden: () => undefined, + }) + + const app = new Elysia() + .onBeforeHandle( + permix.setupMiddleware(() => ({ + post: { create: false, read: false, update: false }, + user: { delete: false }, + })) + ) + .post('/posts', () => ({ success: true }), { + beforeHandle: permix.checkMiddleware('post.create'), + }) + + const res = await app.handle( + new Request('http://localhost/posts', { method: 'POST' }) + ) + expect(res.status).toBe(403) + await expect(res.json()).resolves.toStrictEqual({ error: 'Forbidden' }) + }) +}) + describe('key exposure', () => { it('should expose the key on the factory return', () => { const permix = diff --git a/permix/src/elysia/permix.ts b/permix/src/elysia/permix.ts index 1cc77df0..495be433 100644 --- a/permix/src/elysia/permix.ts +++ b/permix/src/elysia/permix.ts @@ -2,11 +2,11 @@ import type { Context } from 'elysia' import type { Permix as PermixCore } from '../core' import { - createCheckContext, createHooks, createPermix as createPermixCore, createTemplate, PermixNotFoundError, + withDenialReasons, } from '../core' import type { CheckArgs, CheckContext } from '../core/check' import type { Definition } from '../core/definitions' @@ -65,7 +65,7 @@ function buildPermix( typeof callbackOrRules === 'function' ? await callbackOrRules({ context }) : callbackOrRules - const instance = createPermixCore(rules) + const instance = createPermixCore().setup(rules) instance.hook('check', (ctx) => { hooks.callHook('check', ctx) }) @@ -82,7 +82,15 @@ function buildPermix( const allowed = permix.check(...args) if (!allowed) { - return await onForbidden({ context, ...createCheckContext(...args) }) + const result = await onForbidden({ + context, + ...withDenialReasons(permix, args), + }) + if (result !== undefined) { + return result + } + context.set.status = 'Forbidden' + return { error: 'Forbidden' } } } diff --git a/permix/src/express/permix.test.ts b/permix/src/express/permix.test.ts index 3ac31fb7..2c9bb98e 100644 --- a/permix/src/express/permix.test.ts +++ b/permix/src/express/permix.test.ts @@ -485,6 +485,30 @@ describe('onForbidden receives next', () => { }) }) +describe('async middleware errors', () => { + it('should forward a rejecting setup callback to error middleware', async () => { + const permix = createPermix() + + const app = express() + app.use( + permix.setupMiddleware(async () => { + throw new Error('setup failed') + }) + ) + app.post('/posts', (req, res) => { + res.json({ success: true }) + }) + const errorHandler: ErrorRequestHandler = (err, _req, res, _next) => { + res.status(500).json({ error: err.message }) + } + app.use(errorHandler) + + const response = await request(app).post('/posts') + expect(response.status).toBe(500) + expect(response.body).toStrictEqual({ error: 'setup failed' }) + }) +}) + describe('key exposure', () => { it('should expose the key on the factory return', () => { const permix = diff --git a/permix/src/express/permix.ts b/permix/src/express/permix.ts index ac25552b..5b17039f 100644 --- a/permix/src/express/permix.ts +++ b/permix/src/express/permix.ts @@ -1,16 +1,15 @@ import type { Handler, NextFunction, Request, Response } from 'express' import type { Permix as PermixCore } from '../core' -import { - createCheckContext, - createHooks, - createPermix as createPermixCore, - createTemplate, - PermixNotFoundError, -} from '../core' +import { PermixNotFoundError, withDenialReasons } from '../core' import type { CheckArgs, CheckContext } from '../core/check' import type { Definition } from '../core/definitions' -import type { PermixHooks, Rules, RulesPaths } from '../core/permix' +import type { Rules } from '../core/permix' +import { + createRequestKernel, + propertyBagStore, + withContextKey, +} from '../server/kernel' import type { MaybePromise } from '../utils' export interface MiddlewareContext { @@ -29,100 +28,6 @@ export interface PermixOptions { ) => MaybePromise } -function buildPermix( - resolveKey: () => string | symbol, - options: PermixOptions = {} -) { - const onForbidden = - options.onForbidden ?? - (({ res }) => { - res.status(403).json({ error: 'Forbidden' }) - }) - - const hooks = createHooks>() - - function get(req: Request): PermixCore | null { - const instance = (req as any)[resolveKey()] as PermixCore | undefined - return instance ?? null - } - - function getOrThrow(req: Request): PermixCore { - const instance = get(req) - if (!instance) { - throw new PermixNotFoundError(resolveKey()) - } - return instance - } - - function setupMiddleware( - callbackOrRules: - | ((context: MiddlewareContext) => MaybePromise>) - | Rules - ): Handler { - return async (req, res, next) => { - const rules = - typeof callbackOrRules === 'function' - ? await callbackOrRules({ req, res, next }) - : callbackOrRules - const instance = createPermixCore(rules) - instance.hook('check', (context) => { - hooks.callHook('check', context) - }) - ;(req as any)[resolveKey()] = instance - next() - } - } - - const checkMiddleware: (...args: CheckArgs) => Handler = - (...args) => - async (req, res, next) => { - const permix = get(req) - - if (!permix) { - next(new PermixNotFoundError(resolveKey())) - return - } - - const allowed = permix.check(...args) - - if (!allowed) { - await onForbidden({ - req, - res, - next, - ...createCheckContext(...args), - }) - return - } - - next() - } - - function getRules(req: Request): Rules | null { - return get(req)?.getRules() ?? null - } - - function template(rules: Rules | ((param: T) => Rules)) { - return createTemplate(rules) - } - - return { - setupMiddleware, - checkMiddleware, - template, - get, - getOrThrow, - getRules, - hook: hooks.hook, - hookOnce: hooks.hookOnce, - get key() { - return resolveKey() - }, - $inferDefinition: undefined as unknown as D, - $inferPath: undefined as unknown as RulesPaths, - } -} - /** * Create a middleware factory that wires Permix into Express routes. * @@ -153,14 +58,93 @@ function buildPermix( export function createPermix( options: PermixOptions = {} ) { - let key: string | symbol = Symbol('permix') - const permix = buildPermix(() => key, options) - - return Object.assign(permix, { - contextKey(newKey: string | symbol) { - key = newKey - return permix - }, + const onForbidden = + options.onForbidden ?? + (({ res }) => { + res.status(403).json({ error: 'Forbidden' }) + }) + + return withContextKey((resolveKey) => { + const kernel = createRequestKernel( + resolveKey, + propertyBagStore(resolveKey) + ) + + function get(req: Request): PermixCore | null { + return kernel.get(req) + } + + function getOrThrow(req: Request): PermixCore { + return kernel.getOrThrow(req) + } + + function getRules(req: Request): Rules | null { + return kernel.getRules(req) + } + + function setupMiddleware( + callbackOrRules: + | ((context: MiddlewareContext) => MaybePromise>) + | Rules + ): Handler { + return async (req, res, next) => { + try { + const rules = + typeof callbackOrRules === 'function' + ? await callbackOrRules({ req, res, next }) + : callbackOrRules + kernel.attach(req, rules) + next() + } catch (error) { + next(error) + } + } + } + + const checkMiddleware: (...args: CheckArgs) => Handler = + (...args) => + async (req, res, next) => { + try { + const permix = get(req) + + if (!permix) { + next(new PermixNotFoundError(resolveKey())) + return + } + + const allowed = permix.check(...args) + + if (!allowed) { + await onForbidden({ + req, + res, + next, + ...withDenialReasons(permix, args), + }) + return + } + + next() + } catch (error) { + next(error) + } + } + + return { + setupMiddleware, + checkMiddleware, + template: kernel.template, + get, + getOrThrow, + getRules, + hook: kernel.hook, + hookOnce: kernel.hookOnce, + get key() { + return kernel.key + }, + $inferDefinition: kernel.$inferDefinition, + $inferPath: kernel.$inferPath, + } }) } diff --git a/permix/src/extractor/cli.test.ts b/permix/src/extractor/cli.test.ts new file mode 100644 index 00000000..494d7c2b --- /dev/null +++ b/permix/src/extractor/cli.test.ts @@ -0,0 +1,88 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' + +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { parseCliOptions, runCli } from './cli' + +const temporaryDirectories: string[] = [] + +describe('extract CLI', () => { + afterEach(async () => { + vi.restoreAllMocks() + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })) + ) + }) + + describe(parseCliOptions, () => { + it('parses repeatable source and output options', () => { + expect( + parseCliOptions([ + 'extract', + '--cwd', + 'apps/web', + '--include', + 'src/**/*.ts', + '--include', + 'features/**/*.tsx', + '--exclude', + '**/*.test.ts', + '--module-output', + 'src/permissions.generated.ts', + '--check', + ]) + ).toStrictEqual({ + check: true, + help: false, + watch: false, + cwd: 'apps/web', + include: ['src/**/*.ts', 'features/**/*.tsx'], + exclude: ['**/*.test.ts'], + moduleOutput: 'src/permissions.generated.ts', + }) + }) + + it('rejects incompatible and unknown arguments', () => { + expect(() => parseCliOptions(['extract', '--check', '--watch'])).toThrow( + '--check and --watch' + ) + expect(() => parseCliOptions(['extract', '--watc'])).toThrow( + 'Unknown argument' + ) + }) + }) + + describe(runCli, () => { + it('generates and checks artifacts', async () => { + const cwd = await mkdtemp(path.join(tmpdir(), 'permix-cli-')) + temporaryDirectories.push(cwd) + await writeFile( + path.join(cwd, 'permissions.ts'), + `import { permission } from 'permix' +permission('projects.read') +` + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + await expect(runCli(['extract', '--cwd', cwd])).resolves.toBe(0) + await expect(runCli(['extract', '--cwd', cwd, '--check'])).resolves.toBe( + 0 + ) + + await writeFile( + path.join(cwd, 'permissions.ts'), + `import { permission } from 'permix' +permission('projects.update') +` + ) + await expect(runCli(['extract', '--cwd', cwd, '--check'])).resolves.toBe( + 1 + ) + }) + }) +}) diff --git a/permix/src/extractor/cli.ts b/permix/src/extractor/cli.ts new file mode 100644 index 00000000..96fea7a0 --- /dev/null +++ b/permix/src/extractor/cli.ts @@ -0,0 +1,237 @@ +#!/usr/bin/env node + +import path from 'node:path' +import { pathToFileURL } from 'node:url' + +import { PermissionExtractionError } from './error' +import { checkPermissions, generatePermissions } from './generate' +import type { GeneratePermissionsOptions } from './types' +import { watchPermissions } from './watch' + +interface CliOptions extends GeneratePermissionsOptions { + readonly check: boolean + readonly help: boolean + readonly watch: boolean +} + +const HELP = `Usage: permix extract [options] + +Generate a typed permission module and versioned JSON catalog. + +Options: + --cwd Source root (default: current directory) + --include Include glob; may be repeated + --exclude Exclude glob; may be repeated + --module-output Generated TypeScript module + --catalog-output Generated JSON catalog + --check Exit non-zero when artifacts are stale + --watch Regenerate after source changes + --force Rescan every file instead of using the parse cache + --help Show this help +` + +function readValue( + arguments_: readonly string[], + index: number, + flag: string +): string { + const value = arguments_[index + 1] + if (value === undefined || value.startsWith('--')) { + throw new Error(`${flag} requires a value.`) + } + return value +} + +interface ParsedFlags { + include: string[] + exclude: string[] + cwd?: string + moduleOutput?: string + catalogOutput?: string + check: boolean + help: boolean + watch: boolean + force: boolean +} + +function applyFlag( + argument: string, + args: readonly string[], + index: number, + flags: ParsedFlags +): number { + switch (argument) { + case '--cwd': { + flags.cwd = readValue(args, index, argument) + return index + 1 + } + case '--include': { + flags.include.push(readValue(args, index, argument)) + return index + 1 + } + case '--exclude': { + flags.exclude.push(readValue(args, index, argument)) + return index + 1 + } + case '--module-output': { + flags.moduleOutput = readValue(args, index, argument) + return index + 1 + } + case '--catalog-output': { + flags.catalogOutput = readValue(args, index, argument) + return index + 1 + } + case '--check': { + flags.check = true + return index + } + case '--watch': { + flags.watch = true + return index + } + case '--force': { + flags.force = true + return index + } + case '--help': + case '-h': { + flags.help = true + return index + } + default: { + throw new Error(`Unknown argument: ${argument}`) + } + } +} + +export function parseCliOptions(arguments_: readonly string[]): CliOptions { + const args = arguments_[0] === 'extract' ? arguments_.slice(1) : arguments_ + const flags: ParsedFlags = { + include: [], + exclude: [], + check: false, + help: false, + watch: false, + force: false, + } + + for (let index = 0; index < args.length; index += 1) { + const argument = args[index] + if (argument === undefined) { + break + } + index = applyFlag(argument, args, index, flags) + } + + if (flags.check && flags.watch) { + throw new Error('--check and --watch cannot be used together.') + } + + return { + check: flags.check, + help: flags.help, + watch: flags.watch, + ...(flags.cwd === undefined ? {} : { cwd: flags.cwd }), + ...(flags.include.length === 0 ? {} : { include: flags.include }), + ...(flags.exclude.length === 0 ? {} : { exclude: flags.exclude }), + ...(flags.moduleOutput === undefined + ? {} + : { moduleOutput: flags.moduleOutput }), + ...(flags.catalogOutput === undefined + ? {} + : { catalogOutput: flags.catalogOutput }), + ...(flags.force ? { force: true } : {}), + } +} + +function generationOptions(options: CliOptions): GeneratePermissionsOptions { + return { + ...(options.cwd === undefined ? {} : { cwd: options.cwd }), + ...(options.include === undefined ? {} : { include: options.include }), + ...(options.exclude === undefined ? {} : { exclude: options.exclude }), + ...(options.moduleOutput === undefined + ? {} + : { moduleOutput: options.moduleOutput }), + ...(options.catalogOutput === undefined + ? {} + : { catalogOutput: options.catalogOutput }), + ...(options.force === true ? { force: true } : {}), + } +} + +function displayFile(file: string, cwd: string): string { + const relativePath = path.relative(cwd, file) + return relativePath.length === 0 ? file : relativePath +} + +function reportError(error: unknown): void { + if (error instanceof PermissionExtractionError) { + for (const diagnostic of error.diagnostics) { + const location = + diagnostic.line === undefined + ? diagnostic.file + : `${diagnostic.file}:${diagnostic.line}:${diagnostic.column ?? 1}` + console.error(`${location} [${diagnostic.code}] ${diagnostic.message}`) + } + return + } + + console.error(error instanceof Error ? error.message : String(error)) +} + +export async function runCli(arguments_: readonly string[]): Promise { + try { + const options = parseCliOptions(arguments_) + if (options.help) { + console.log(HELP) + return 0 + } + + const generateOptions = generationOptions(options) + const cwd = path.resolve(options.cwd ?? process.cwd()) + + if (options.check) { + const result = await checkPermissions(generateOptions) + if (!result.valid) { + for (const file of result.stale) { + console.error(`Stale permission artifact: ${displayFile(file, cwd)}`) + } + return 1 + } + + console.log( + `Permission artifacts are current (${result.catalog.permissions.length} permissions).` + ) + return 0 + } + + if (options.watch) { + await watchPermissions(generateOptions, (event) => { + if (event.type === 'error') { + reportError(event.error) + } else if (event.result.catalogChanged || event.result.moduleChanged) { + console.log( + `Generated ${event.result.catalog.permissions.length} permissions.` + ) + } + }) + console.log(`Watching ${cwd} for permission changes.`) + return 0 + } + + const result = await generatePermissions(generateOptions) + console.log(`Generated ${result.catalog.permissions.length} permissions.`) + return 0 + } catch (error) { + reportError(error) + return 1 + } +} + +const executable = process.argv[1] +if ( + executable !== undefined && + pathToFileURL(path.resolve(executable)).href === import.meta.url +) { + process.exitCode = await runCli(process.argv.slice(2)) +} diff --git a/permix/src/extractor/deps.test.ts b/permix/src/extractor/deps.test.ts new file mode 100644 index 00000000..dfbfeb67 --- /dev/null +++ b/permix/src/extractor/deps.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest' + +import { importOxcParser } from './deps' + +describe('extractor optional peers', () => { + it('loads oxc-parser from the workspace install', async () => { + const parser = await importOxcParser() + expect(parser.parseSync).toBeTypeOf('function') + expect(parser.Visitor).toBeTypeOf('function') + }) +}) diff --git a/permix/src/extractor/deps.ts b/permix/src/extractor/deps.ts new file mode 100644 index 00000000..1876ad44 --- /dev/null +++ b/permix/src/extractor/deps.ts @@ -0,0 +1,39 @@ +import type * as Chokidar from 'chokidar' +import type * as OxcParser from 'oxc-parser' +import type * as Tinyglobby from 'tinyglobby' + +/** + * Extractor-only packages are optional peers so a UI-only `permix` install + * does not download native parser binaries. Dynamic import is required so + * missing peers fail at CLI / `withPermix` time instead of at module load. + */ +const EXTRACTOR_INSTALL_HINT = + 'Install chokidar, oxc-parser, and tinyglobby (or reinstall permix with optional dependencies enabled) to use the Permix CLI or withPermix.' + +function missingExtractorDependency(error: unknown): Error { + return new Error(`[Permix]: ${EXTRACTOR_INSTALL_HINT}`, { cause: error }) +} + +export async function importOxcParser(): Promise { + try { + return await import('oxc-parser') + } catch (error) { + throw missingExtractorDependency(error) + } +} + +export async function importTinyglobby(): Promise { + try { + return await import('tinyglobby') + } catch (error) { + throw missingExtractorDependency(error) + } +} + +export async function importChokidar(): Promise { + try { + return await import('chokidar') + } catch (error) { + throw missingExtractorDependency(error) + } +} diff --git a/permix/src/extractor/error.ts b/permix/src/extractor/error.ts new file mode 100644 index 00000000..6d1402c1 --- /dev/null +++ b/permix/src/extractor/error.ts @@ -0,0 +1,16 @@ +import type { PermissionDiagnostic } from './types' + +export class PermissionExtractionError extends Error { + readonly diagnostics: readonly PermissionDiagnostic[] + + constructor(diagnostics: readonly PermissionDiagnostic[]) { + const summary = + diagnostics.length === 1 + ? diagnostics[0]?.message + : `Permission extraction failed with ${diagnostics.length} errors.` + + super(summary) + this.name = 'PermissionExtractionError' + this.diagnostics = diagnostics + } +} diff --git a/permix/src/extractor/extract.test.ts b/permix/src/extractor/extract.test.ts new file mode 100644 index 00000000..36e48941 --- /dev/null +++ b/permix/src/extractor/extract.test.ts @@ -0,0 +1,155 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' + +import { afterEach, describe, expect, it } from 'vitest' + +import { PermissionExtractionError } from './error' +import { createPermissionFileCache, extractPermissions } from './extract' + +const temporaryDirectories: string[] = [] + +async function createProject( + files: Readonly> +): Promise { + const directory = await mkdtemp(path.join(tmpdir(), 'permix-extractor-')) + temporaryDirectories.push(directory) + + await Promise.all( + Object.entries(files).map(async ([file, source]) => { + const absoluteFile = path.join(directory, file) + await mkdir(path.dirname(absoluteFile), { recursive: true }) + await writeFile(absoluteFile, source) + }) + ) + + return directory +} + +describe(extractPermissions, () => { + afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })) + ) + }) + + it('sorts keys and deduplicates references deterministically', async () => { + const cwd = await createProject({ + 'src/a.ts': `import { permission } from 'permix' +permission({ key: 'projects.read', title: 'Read projects' }) +`, + 'src/z.ts': `import { permission as mark } from 'permix' +mark('workspace.members.invite') +mark('projects.read') +`, + }) + + await expect(extractPermissions({ cwd })).resolves.toStrictEqual({ + schemaVersion: 1, + permissions: [ + { + key: 'projects.read', + title: 'Read projects', + references: [ + { file: 'src/a.ts', line: 2, column: 1 }, + { file: 'src/z.ts', line: 3, column: 1 }, + ], + }, + { + key: 'workspace.members.invite', + references: [{ file: 'src/z.ts', line: 2, column: 1 }], + }, + ], + }) + }) + + it('applies central metadata after inline metadata', async () => { + const cwd = await createProject({ + 'src/permissions.ts': `import { permission } from 'permix' +permission({ key: 'projects.read', title: 'Inline title' }) +`, + }) + + const catalog = await extractPermissions({ + cwd, + metadata: { + 'projects.read': { + title: 'Configured title', + description: 'Read project details.', + }, + }, + }) + + expect(catalog.permissions[0]).toMatchObject({ + key: 'projects.read', + title: 'Configured title', + description: 'Read project details.', + }) + }) + + it('rejects conflicting duplicate metadata', async () => { + const cwd = await createProject({ + 'src/a.ts': `import { permission } from 'permix' +permission({ key: 'projects.read', title: 'First title' }) +`, + 'src/b.ts': `import { permission } from 'permix' +permission({ key: 'projects.read', title: 'Second title' }) +`, + }) + + await expect(extractPermissions({ cwd })).rejects.toMatchObject({ + diagnostics: [ + { + code: 'conflicting-metadata', + file: 'src/b.ts', + }, + ], + }) + }) + + it('rejects metadata entries without an extracted permission', async () => { + const cwd = await createProject({}) + + await expect( + extractPermissions({ + cwd, + metadata: { + 'projects.deleted': { + title: 'Stale permission', + }, + }, + }) + ).rejects.toBeInstanceOf(PermissionExtractionError) + }) + + it('reuses parse results when mtime and size are unchanged', async () => { + const cwd = await createProject({ + 'src/a.ts': `import { permission } from 'permix' +permission('projects.read') +`, + 'src/b.ts': `import { permission } from 'permix' +permission('projects.update') +`, + }) + const cache = createPermissionFileCache() + + const first = await extractPermissions({ cwd, cache }) + expect(cache.misses).toBe(2) + expect(cache.hits).toBe(0) + expect(first.permissions.map((permission) => permission.key)).toStrictEqual( + ['projects.read', 'projects.update'] + ) + + const second = await extractPermissions({ cwd, cache }) + expect(cache.misses).toBe(0) + expect(cache.hits).toBe(2) + expect(second).toStrictEqual(first) + + const forced = await extractPermissions({ cwd, cache, force: true }) + expect(cache.misses).toBe(2) + expect(cache.hits).toBe(0) + expect(forced).toStrictEqual(first) + }) +}) diff --git a/permix/src/extractor/extract.ts b/permix/src/extractor/extract.ts new file mode 100644 index 00000000..44fd4e22 --- /dev/null +++ b/permix/src/extractor/extract.ts @@ -0,0 +1,318 @@ +import { readFile, stat } from 'node:fs/promises' +import path from 'node:path' + +import type { + JsonObject, + JsonValue, + PermissionMetadata, +} from '../core/permission' +import { importTinyglobby } from './deps' +import { PermissionExtractionError } from './error' +import { parsePermissionFile } from './parse' +import type { ExtractedPermission } from './parse' +import { PERMISSION_CATALOG_SCHEMA_VERSION } from './types' +import type { + ExtractPermissionsOptions, + PermissionCatalog, + PermissionCatalogEntry, + PermissionDiagnostic, + PermissionReference, +} from './types' + +const DEFAULT_INCLUDE = ['**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}'] +const DEFAULT_EXCLUDE = [ + '**/.git/**', + '**/.next/**', + '**/.nuxt/**', + '**/.output/**', + '**/.permix/**', + '**/coverage/**', + '**/dist/**', + '**/node_modules/**', +] + +function normalizePath(filePath: string): string { + return path.sep === '/' ? filePath : filePath.split(path.sep).join('/') +} + +function canonicalJson(value: JsonValue): string { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value) + } + + if (Array.isArray(value)) { + return `[${value.map(canonicalJson).join(',')}]` + } + + return `{${Object.entries(value) + .toSorted(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`) + .join(',')}}` +} + +function metadataValue( + metadata: PermissionMetadata, + key: keyof PermissionMetadata +): JsonValue | undefined { + return metadata[key] +} + +function mergeMetadata( + key: string, + current: PermissionMetadata, + incoming: PermissionMetadata, + reference: PermissionReference, + diagnostics: PermissionDiagnostic[] +): PermissionMetadata { + const merged: { + annotations?: JsonObject + description?: string + tags?: readonly string[] + title?: string + } = { ...current } + + for (const field of [ + 'annotations', + 'description', + 'tags', + 'title', + ] as const) { + const currentValue = metadataValue(current, field) + const incomingValue = metadataValue(incoming, field) + + if (incomingValue === undefined) { + continue + } + + if ( + currentValue !== undefined && + canonicalJson(currentValue) !== canonicalJson(incomingValue) + ) { + diagnostics.push({ + code: 'conflicting-metadata', + file: reference.file, + line: reference.line, + column: reference.column, + message: `Permission "${key}" has conflicting "${field}" metadata.`, + }) + continue + } + + if (field === 'annotations') { + merged.annotations = incomingValue as JsonObject + } else if (field === 'tags') { + merged.tags = incomingValue as readonly string[] + } else { + merged[field] = incomingValue as string + } + } + + return merged +} + +function compareReferences( + left: PermissionReference, + right: PermissionReference +): number { + return ( + left.file.localeCompare(right.file) || + left.line - right.line || + left.column - right.column + ) +} + +function buildCatalog( + permissions: readonly ExtractedPermission[], + options: ExtractPermissionsOptions +): PermissionCatalog { + const entries = new Map< + string, + { + metadata: PermissionMetadata + references: PermissionReference[] + } + >() + const diagnostics: PermissionDiagnostic[] = [] + + for (const permission of permissions) { + const existing = entries.get(permission.key) + if (existing === undefined) { + entries.set(permission.key, { + metadata: permission.metadata, + references: [permission.reference], + }) + continue + } + + existing.metadata = mergeMetadata( + permission.key, + existing.metadata, + permission.metadata, + permission.reference, + diagnostics + ) + existing.references.push(permission.reference) + } + + if (options.metadata !== undefined) { + for (const [key, metadata] of Object.entries(options.metadata)) { + const entry = entries.get(key) + if (entry === undefined) { + diagnostics.push({ + code: 'stale-metadata', + file: '', + message: `Metadata config references unknown permission "${key}".`, + }) + continue + } + + entry.metadata = { + ...entry.metadata, + ...metadata, + } + } + } + + if (diagnostics.length > 0) { + throw new PermissionExtractionError(diagnostics) + } + + const catalogEntries: PermissionCatalogEntry[] = [] + for (const [key, entry] of entries) { + catalogEntries.push({ + key, + ...entry.metadata, + references: entry.references.toSorted(compareReferences), + }) + } + + return { + schemaVersion: PERMISSION_CATALOG_SCHEMA_VERSION, + permissions: catalogEntries.toSorted((left, right) => + left.key.localeCompare(right.key) + ), + } +} + +export interface PermissionFileCacheEntry { + readonly mtimeMs: number + readonly size: number + readonly diagnostics: readonly PermissionDiagnostic[] + readonly permissions: readonly ExtractedPermission[] +} + +export interface PermissionFileCache { + hits: number + misses: number + clear: () => void + delete: (absoluteFile: string) => void + get: (absoluteFile: string) => PermissionFileCacheEntry | undefined + keys: () => IterableIterator + set: (absoluteFile: string, entry: PermissionFileCacheEntry) => void +} + +export function createPermissionFileCache(): PermissionFileCache { + const files = new Map() + + return { + hits: 0, + misses: 0, + clear() { + files.clear() + this.hits = 0 + this.misses = 0 + }, + delete(absoluteFile) { + files.delete(absoluteFile) + }, + get(absoluteFile) { + return files.get(absoluteFile) + }, + keys() { + return files.keys() + }, + set(absoluteFile, entry) { + files.set(absoluteFile, entry) + }, + } +} + +async function parseCachedFile( + cache: PermissionFileCache, + cwd: string, + absoluteFile: string, + counters: { hits: number; misses: number } +): Promise { + const stats = await stat(absoluteFile) + const cached = cache.get(absoluteFile) + if ( + cached !== undefined && + cached.mtimeMs === stats.mtimeMs && + cached.size === stats.size + ) { + counters.hits += 1 + return cached + } + + counters.misses += 1 + const source = await readFile(absoluteFile, 'utf-8') + const file = normalizePath(path.relative(cwd, absoluteFile)) + const parsed = await parsePermissionFile(file, source) + const entry: PermissionFileCacheEntry = { + mtimeMs: stats.mtimeMs, + size: stats.size, + diagnostics: parsed.diagnostics, + permissions: parsed.permissions, + } + cache.set(absoluteFile, entry) + return entry +} + +export async function extractPermissions( + options: ExtractPermissionsOptions = {} +): Promise { + const cwd = path.resolve(options.cwd ?? process.cwd()) + const cache = options.cache ?? createPermissionFileCache() + if (options.force === true) { + cache.clear() + } + + const { glob } = await importTinyglobby() + const files = await glob(options.include ?? DEFAULT_INCLUDE, { + absolute: true, + cwd, + dot: true, + followSymbolicLinks: false, + ignore: options.exclude ?? DEFAULT_EXCLUDE, + }) + const fileSet = new Set(files) + for (const cachedFile of cache.keys()) { + if (!fileSet.has(cachedFile)) { + cache.delete(cachedFile) + } + } + + const counters = { hits: 0, misses: 0 } + const parsedFiles = await Promise.all( + files + .toSorted() + .map((absoluteFile) => + parseCachedFile(cache, cwd, absoluteFile, counters) + ) + ) + cache.hits = counters.hits + cache.misses = counters.misses + + const diagnostics: PermissionDiagnostic[] = parsedFiles.flatMap( + ({ diagnostics: fileDiagnostics }) => fileDiagnostics + ) + const permissions: ExtractedPermission[] = parsedFiles.flatMap( + ({ permissions: filePermissions }) => filePermissions + ) + + if (diagnostics.length > 0) { + throw new PermissionExtractionError(diagnostics) + } + + return buildCatalog(permissions, options) +} diff --git a/permix/src/extractor/generate.test.ts b/permix/src/extractor/generate.test.ts new file mode 100644 index 00000000..b20efd76 --- /dev/null +++ b/permix/src/extractor/generate.test.ts @@ -0,0 +1,151 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' + +import { afterEach, describe, expect, it } from 'vitest' + +import { PermissionExtractionError } from './error' +import { + checkPermissions, + generatePermissions, + renderPermissionModule, +} from './generate' +import type { PermissionCatalog } from './types' + +const temporaryDirectories: string[] = [] + +async function createProject(source: string): Promise<{ + readonly cwd: string + readonly sourceFile: string +}> { + const cwd = await mkdtemp(path.join(tmpdir(), 'permix-generator-')) + temporaryDirectories.push(cwd) + const sourceFile = path.join(cwd, 'permissions.ts') + await writeFile(sourceFile, source) + return { cwd, sourceFile } +} + +describe('permission artifacts', () => { + afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })) + ) + }) + + describe(renderPermissionModule, () => { + it('renders typed keys, nested constants, metadata, and a definition', () => { + const catalog: PermissionCatalog = { + schemaVersion: 1, + permissions: [ + { + key: 'projects.read', + title: 'Read projects', + references: [{ file: 'src/a.ts', line: 1, column: 1 }], + }, + { + key: 'workspace.members.invite', + references: [{ file: 'src/b.ts', line: 2, column: 3 }], + }, + ], + } + + const source = renderPermissionModule(catalog) + + expect(source).toContain( + 'export type Permission = (typeof permissionKeys)[number]' + ) + expect(source).toContain("read: 'projects.read'") + expect(source).toContain("invite: 'workspace.members.invite'") + expect(source).toContain("title: 'Read projects'") + expect(source).toContain('members: {\n invite') + expect(source).toContain('createPermissionOverlay()') + }) + + it('rejects reserved generated property segments', () => { + for (const segment of [ + '__proto__', + 'constructor', + 'prototype', + ] as const) { + const catalog: PermissionCatalog = { + schemaVersion: 1, + permissions: [ + { + key: `post.${segment}`, + references: [], + }, + ], + } + + expect(() => renderPermissionModule(catalog)).toThrow( + PermissionExtractionError + ) + } + }) + + it('rejects paths that cannot form one Permix definition tree', () => { + const catalog: PermissionCatalog = { + schemaVersion: 1, + permissions: [ + { + key: 'projects.read', + references: [], + }, + { + key: 'projects.admin.delete', + references: [], + }, + ], + } + + expect(() => renderPermissionModule(catalog)).toThrow( + PermissionExtractionError + ) + }) + }) + + describe(generatePermissions, () => { + it('writes stable artifacts and leaves valid output on scan failure', async () => { + const { cwd, sourceFile } = await createProject( + `import { permission } from 'permix' +permission({ key: 'tasks.comment', title: 'Comment on tasks' }) +` + ) + + const first = await generatePermissions({ cwd }) + const moduleFile = path.join(cwd, '.permix/permissions.ts') + const catalogFile = path.join(cwd, '.permix/permissions.json') + const validModule = await readFile(moduleFile, 'utf-8') + const validCatalog = await readFile(catalogFile, 'utf-8') + + expect(first.moduleChanged).toBe(true) + expect(first.catalogChanged).toBe(true) + await expect(checkPermissions({ cwd })).resolves.toMatchObject({ + valid: true, + stale: [], + }) + await expect(generatePermissions({ cwd })).resolves.toMatchObject({ + moduleChanged: false, + catalogChanged: false, + }) + + await writeFile( + sourceFile, + `import { permission } from 'permix' +permission(getPermissionKey()) +` + ) + + await expect(generatePermissions({ cwd })).rejects.toBeInstanceOf( + PermissionExtractionError + ) + await expect(readFile(moduleFile, 'utf-8')).resolves.toBe(validModule) + await expect(readFile(catalogFile, 'utf-8')).resolves.toBe(validCatalog) + await expect(checkPermissions({ cwd })).rejects.toBeInstanceOf( + PermissionExtractionError + ) + }) + }) +}) diff --git a/permix/src/extractor/generate.ts b/permix/src/extractor/generate.ts new file mode 100644 index 00000000..a5028266 --- /dev/null +++ b/permix/src/extractor/generate.ts @@ -0,0 +1,391 @@ +import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' +import path from 'node:path' + +import type { JsonValue } from '../core/permission' +import { PermissionExtractionError } from './error' +import { extractPermissions } from './extract' +import type { + GeneratePermissionsOptions, + PermissionCatalog, + PermissionCatalogEntry, + PermissionDiagnostic, +} from './types' + +type GeneratedDefinition = + | readonly string[] + | { readonly [key: string]: GeneratedDefinition } + +interface PermissionTree { + readonly actions: Set + readonly children: Map +} + +const RESERVED_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']) + +function createTree(): PermissionTree { + return { + actions: new Set(), + children: new Map(), + } +} + +function propertyCollision(key: string, message: string): PermissionDiagnostic { + return { + code: 'invalid-permission-key', + file: '', + message: `Permission "${key}" ${message}`, + } +} + +function buildTree(catalog: PermissionCatalog): PermissionTree { + const root = createTree() + const diagnostics: PermissionDiagnostic[] = [] + + for (const permission of catalog.permissions) { + const segments = permission.key.split('.') + const reservedSegment = segments.find((segment) => + RESERVED_SEGMENTS.has(segment) + ) + if (reservedSegment !== undefined) { + diagnostics.push( + propertyCollision( + permission.key, + `uses reserved generated property "${reservedSegment}".` + ) + ) + continue + } + + let node = root + let valid = true + for (const segment of segments.slice(0, -1)) { + if (node.actions.size > 0) { + diagnostics.push( + propertyCollision( + permission.key, + 'collides with a permission action at the same tree level.' + ) + ) + valid = false + break + } + + const child = node.children.get(segment) ?? createTree() + node.children.set(segment, child) + node = child + } + + if (!valid) { + continue + } + + const action = segments.at(-1) + if (action === undefined) { + continue + } + if (node.children.size > 0) { + diagnostics.push( + propertyCollision( + permission.key, + 'collides with a nested permission group at the same tree level.' + ) + ) + continue + } + node.actions.add(action) + } + + if (diagnostics.length > 0) { + throw new PermissionExtractionError(diagnostics) + } + + return root +} + +function definitionFromTree(tree: PermissionTree): GeneratedDefinition { + if (tree.actions.size > 0) { + return [...tree.actions].toSorted() + } + + const definition: Record = Object.create(null) + for (const [key, child] of [...tree.children].toSorted(([left], [right]) => + left.localeCompare(right) + )) { + definition[key] = definitionFromTree(child) + } + return definition +} + +function constantsFromTree( + tree: PermissionTree, + prefix = '' +): Readonly> { + const constants: Record = Object.create(null) + + if (tree.actions.size > 0) { + for (const action of [...tree.actions].toSorted()) { + constants[action] = `${prefix}${action}` + } + return constants + } + + for (const [key, child] of [...tree.children].toSorted(([left], [right]) => + left.localeCompare(right) + )) { + constants[key] = constantsFromTree(child, `${prefix}${key}.`) + } + + return constants +} + +function normalizeJson(value: JsonValue): JsonValue { + if (value === null || typeof value !== 'object') { + return value + } + + if (Array.isArray(value)) { + return value.map(normalizeJson) + } + + const normalized: Record = Object.create(null) + for (const [key, item] of Object.entries(value).toSorted(([left], [right]) => + left.localeCompare(right) + )) { + normalized[key] = normalizeJson(item) + } + return normalized +} + +function metadataFromEntry( + entry: PermissionCatalogEntry +): Readonly> { + const metadata: Record = Object.create(null) + if (entry.title !== undefined) { + metadata.title = entry.title + } + if (entry.description !== undefined) { + metadata.description = entry.description + } + if (entry.tags !== undefined) { + metadata.tags = entry.tags + } + if (entry.annotations !== undefined) { + metadata.annotations = normalizeJson(entry.annotations) + } + return metadata +} + +function stringify(value: unknown): string { + return JSON.stringify(value, null, 2) +} + +function quoteTypeScriptString(value: string): string { + const jsonContents = JSON.stringify(value).slice(1, -1) + return `'${jsonContents.replaceAll("'", "\\'").replaceAll('\\"', '"')}'` +} + +function typeScriptProperty(key: string): string { + return /^[$A-Z_a-z][$\w]*$/u.test(key) ? key : quoteTypeScriptString(key) +} + +function renderTypeScriptValue(value: JsonValue, depth = 0): string { + if (typeof value === 'string') { + return quoteTypeScriptString(value) + } + if (value === null || typeof value !== 'object') { + return String(value) + } + + const indentation = ' '.repeat(depth) + const itemIndentation = ' '.repeat(depth + 1) + if (Array.isArray(value)) { + if (value.length === 0) { + return '[]' + } + const items = value + .map( + (item) => `${itemIndentation}${renderTypeScriptValue(item, depth + 1)},` + ) + .join('\n') + return `[\n${items}\n${indentation}]` + } + + const entries = Object.entries(value) + if (entries.length === 0) { + return '{}' + } + const properties = entries + .map( + ([key, item]) => + `${itemIndentation}${typeScriptProperty(key)}: ${renderTypeScriptValue( + item, + depth + 1 + )},` + ) + .join('\n') + return `{\n${properties}\n${indentation}}` +} + +export function renderPermissionModule(catalog: PermissionCatalog): string { + const tree = buildTree(catalog) + const permissionKeys = catalog.permissions.map(({ key }) => key) + const metadata: Record< + string, + Readonly> + > = Object.create(null) + + for (const entry of catalog.permissions) { + metadata[entry.key] = metadataFromEntry(entry) + } + + return `/* This file is generated by Permix. Do not edit it directly. */ +import { + createPermissionConfig, + createPermissionOverlay, +} from 'permix' +import type { + ApplyPermissionOverlay, + Definition as PermixDefinition, +} from 'permix' + +export type { PermissionReference } from 'permix/extractor' + +export const permissionKeys = ${renderTypeScriptValue(permissionKeys)} as const + +export type Permission = (typeof permissionKeys)[number] + +export const permissions = ${renderTypeScriptValue(constantsFromTree(tree))} as const + +export const permissionMetadata = ${renderTypeScriptValue(metadata)} as const + +export const permissionDefinition = ${renderTypeScriptValue(definitionFromTree(tree))} as const + +export type ExtractedDefinition = typeof permissionDefinition + +export type Definition< + Overlay extends PermixDefinition = ExtractedDefinition, +> = ApplyPermissionOverlay + +export const definePermissionConfig = + createPermissionConfig() + +export const definePermissionOverlay = + createPermissionOverlay() +` +} + +export function renderPermissionCatalog(catalog: PermissionCatalog): string { + return `${stringify(normalizeJson(catalog as unknown as JsonValue))}\n` +} + +async function readExisting(file: string): Promise { + try { + return await readFile(file, 'utf-8') + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return undefined + } + throw error + } +} + +async function writeAtomically( + file: string, + contents: string +): Promise { + if ((await readExisting(file)) === contents) { + return false + } + + await mkdir(path.dirname(file), { recursive: true }) + const temporaryFile = `${file}.${process.pid}.${Date.now()}.tmp` + + try { + await writeFile(temporaryFile, contents) + await rename(temporaryFile, file) + } finally { + await rm(temporaryFile, { force: true }) + } + + return true +} + +export interface GeneratePermissionsResult { + readonly catalog: PermissionCatalog + readonly catalogChanged: boolean + readonly moduleChanged: boolean +} + +export interface CheckPermissionsResult { + readonly catalog: PermissionCatalog + readonly stale: readonly string[] + readonly valid: boolean +} + +function extractionOptions(options: GeneratePermissionsOptions, cwd: string) { + return { + cwd, + ...(options.include === undefined ? {} : { include: options.include }), + ...(options.exclude === undefined ? {} : { exclude: options.exclude }), + ...(options.metadata === undefined ? {} : { metadata: options.metadata }), + ...(options.cache === undefined ? {} : { cache: options.cache }), + ...(options.force === true ? { force: true } : {}), + } +} + +function outputFiles(options: GeneratePermissionsOptions, cwd: string) { + return { + moduleOutput: path.resolve( + cwd, + options.moduleOutput ?? '.permix/permissions.ts' + ), + catalogOutput: path.resolve( + cwd, + options.catalogOutput ?? '.permix/permissions.json' + ), + } +} + +export async function checkPermissions( + options: GeneratePermissionsOptions = {} +): Promise { + const cwd = path.resolve(options.cwd ?? process.cwd()) + const catalog = await extractPermissions(extractionOptions(options, cwd)) + const { moduleOutput, catalogOutput } = outputFiles(options, cwd) + const expected = new Map([ + [moduleOutput, renderPermissionModule(catalog)], + [catalogOutput, renderPermissionCatalog(catalog)], + ]) + const compared = await Promise.all( + [...expected].map(async ([file, contents]) => + (await readExisting(file)) === contents ? undefined : file + ) + ) + const stale = compared.filter((file): file is string => file !== undefined) + + return { + catalog, + stale, + valid: stale.length === 0, + } +} + +export async function generatePermissions( + options: GeneratePermissionsOptions = {} +): Promise { + const cwd = path.resolve(options.cwd ?? process.cwd()) + const catalog = await extractPermissions(extractionOptions(options, cwd)) + const { moduleOutput, catalogOutput } = outputFiles(options, cwd) + const moduleContents = renderPermissionModule(catalog) + const catalogContents = renderPermissionCatalog(catalog) + const [moduleChanged, catalogChanged] = await Promise.all([ + writeAtomically(moduleOutput, moduleContents), + writeAtomically(catalogOutput, catalogContents), + ]) + + return { + catalog, + catalogChanged, + moduleChanged, + } +} diff --git a/permix/src/extractor/index.ts b/permix/src/extractor/index.ts new file mode 100644 index 00000000..f5874d04 --- /dev/null +++ b/permix/src/extractor/index.ts @@ -0,0 +1,6 @@ +export * from './error' +export * from './extract' +export * from './generate' +export type * from './types' +export * from './validate' +export * from './watch' diff --git a/permix/src/extractor/parse.test.ts b/permix/src/extractor/parse.test.ts new file mode 100644 index 00000000..b259d7a2 --- /dev/null +++ b/permix/src/extractor/parse.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest' + +import { parsePermissionFile } from './parse' + +describe(parsePermissionFile, () => { + it('extracts aliased and namespace markers with static metadata', async () => { + const source = `import { permission as mark } from 'permix' +import * as permix from 'permix' + +export const update = mark({ + key: 'projects.update', + title: 'Update project', + tags: ['projects', 'write'], + annotations: { risk: 2, elevated: true, owner: null }, +} as const) + +export const invite = permix.permission(\`workspace.members.invite\`) +` + + const result = await parsePermissionFile('src/permissions.ts', source) + + expect(result.diagnostics).toStrictEqual([]) + expect(result.permissions).toStrictEqual([ + { + key: 'projects.update', + metadata: { + title: 'Update project', + tags: ['projects', 'write'], + annotations: { + risk: 2, + elevated: true, + owner: null, + }, + }, + reference: { + file: 'src/permissions.ts', + line: 4, + column: 23, + }, + }, + { + key: 'workspace.members.invite', + metadata: {}, + reference: { + file: 'src/permissions.ts', + line: 11, + column: 23, + }, + }, + ]) + }) + + it('ignores unrelated functions named permission', async () => { + const source = `const permission = (key: string) => key +permission(dynamicKey) +` + + await expect( + parsePermissionFile('src/unrelated.ts', source) + ).resolves.toStrictEqual({ + diagnostics: [], + permissions: [], + }) + }) + + it('rejects dynamic keys and metadata', async () => { + const source = `import { permission } from 'permix' +const key = 'projects.update' +permission(key) +permission({ key: 'projects.read', title: getTitle() }) +` + + const result = await parsePermissionFile('src/dynamic.ts', source) + + expect(result.permissions).toStrictEqual([]) + expect(result.diagnostics).toMatchObject([ + { + code: 'dynamic-value', + file: 'src/dynamic.ts', + line: 3, + }, + { + code: 'dynamic-value', + file: 'src/dynamic.ts', + line: 4, + }, + ]) + }) + + it('rejects invalid permission paths and marker properties', async () => { + const source = `import { permission } from 'permix' +permission('projects..update') +permission({ key: 'projects.read', titel: 'Read project' }) +` + + const result = await parsePermissionFile('src/invalid.ts', source) + + expect(result.permissions).toStrictEqual([]) + expect(result.diagnostics).toMatchObject([ + { code: 'invalid-permission-key', line: 2 }, + { code: 'invalid-marker', line: 3 }, + ]) + }) + + it('fails conservatively when the imported marker is shadowed', async () => { + const source = `import { permission } from 'permix' +function run(permission: (key: string) => string) { + return permission('projects.update') +} +` + + const result = await parsePermissionFile('src/shadowed.ts', source) + + expect(result.permissions).toStrictEqual([]) + expect(result.diagnostics).toMatchObject([ + { + code: 'marker-shadowed', + file: 'src/shadowed.ts', + line: 2, + }, + ]) + }) +}) diff --git a/permix/src/extractor/parse.ts b/permix/src/extractor/parse.ts new file mode 100644 index 00000000..ecd39111 --- /dev/null +++ b/permix/src/extractor/parse.ts @@ -0,0 +1,634 @@ +import type { + Argument, + BindingPattern, + CallExpression, + Expression, + ObjectExpression, + ObjectProperty, + ParamPattern, +} from 'oxc-parser' + +import type { + JsonObject, + JsonValue, + PermissionMetadata, +} from '../core/permission' +import { importOxcParser } from './deps' +import type { PermissionDiagnostic, PermissionReference } from './types' + +export interface ExtractedPermission { + readonly key: string + readonly metadata: PermissionMetadata + readonly reference: PermissionReference +} + +interface ParsedPermissionFile { + readonly diagnostics: readonly PermissionDiagnostic[] + readonly permissions: readonly ExtractedPermission[] +} + +interface StaticValueSuccess { + readonly ok: true + readonly value: JsonValue +} + +interface StaticValueFailure { + readonly ok: false +} + +type StaticValueResult = StaticValueFailure | StaticValueSuccess + +const MARKER_PROPERTIES = new Set([ + 'annotations', + 'description', + 'key', + 'tags', + 'title', +]) + +function unwrapExpression(expression: Expression): Expression { + if ( + expression.type === 'ParenthesizedExpression' || + expression.type === 'TSAsExpression' || + expression.type === 'TSNonNullExpression' || + expression.type === 'TSSatisfiesExpression' || + expression.type === 'TSTypeAssertion' + ) { + return unwrapExpression(expression.expression) + } + + return expression +} + +function propertyName(property: ObjectProperty): string | undefined { + if (property.computed) { + return undefined + } + + if (property.key.type === 'Identifier') { + return property.key.name + } + + if ( + property.key.type === 'Literal' && + typeof property.key.value === 'string' + ) { + return property.key.value + } + + return undefined +} + +function readStaticPrimitive(value: Expression): StaticValueResult { + if (value.type === 'Literal') { + if ( + value.value === null || + typeof value.value === 'boolean' || + typeof value.value === 'string' + ) { + return { ok: true, value: value.value } + } + + if (typeof value.value === 'number' && Number.isFinite(value.value)) { + return { ok: true, value: value.value } + } + + return { ok: false } + } + + if (value.type === 'TemplateLiteral') { + if (value.expressions.length !== 0 || value.quasis.length !== 1) { + return { ok: false } + } + + return { + ok: true, + value: value.quasis[0]?.value.cooked ?? value.quasis[0]?.value.raw ?? '', + } + } + + if (value.type === 'UnaryExpression' && value.operator === '-') { + const argument = unwrapExpression(value.argument) + if ( + argument.type === 'Literal' && + typeof argument.value === 'number' && + Number.isFinite(argument.value) + ) { + return { ok: true, value: -argument.value } + } + } + + return { ok: false } +} + +function readStaticArray( + value: Extract +): StaticValueResult { + const result: JsonValue[] = [] + for (const element of value.elements) { + if (element === null || element.type === 'SpreadElement') { + return { ok: false } + } + + const item = readStaticJson(element) + if (!item.ok) { + return item + } + result.push(item.value) + } + return { ok: true, value: result } +} + +function readStaticObject(value: ObjectExpression): StaticValueResult { + const result: Record = {} + for (const property of value.properties) { + if ( + property.type === 'SpreadElement' || + property.kind !== 'init' || + property.method || + property.shorthand + ) { + return { ok: false } + } + + const name = propertyName(property) + if (name === undefined || Object.hasOwn(result, name)) { + return { ok: false } + } + + const item = readStaticJson(property.value) + if (!item.ok) { + return item + } + Object.defineProperty(result, name, { + configurable: true, + enumerable: true, + value: item.value, + writable: true, + }) + } + return { ok: true, value: result } +} + +function readStaticJson(expression: Expression): StaticValueResult { + const value = unwrapExpression(expression) + + if (value.type === 'ArrayExpression') { + return readStaticArray(value) + } + if (value.type === 'ObjectExpression') { + return readStaticObject(value) + } + return readStaticPrimitive(value) +} + +function lineAndColumn( + source: string, + offset: number +): Pick { + let line = 1 + let lineStart = 0 + + for (let index = 0; index < offset; index += 1) { + if (source.codePointAt(index) === 10) { + line += 1 + lineStart = index + 1 + } + } + + return { + line, + column: offset - lineStart + 1, + } +} + +function diagnosticAt( + source: string, + file: string, + start: number, + diagnostic: Omit +): PermissionDiagnostic { + return { + ...diagnostic, + file, + ...lineAndColumn(source, start), + } +} + +type MarkerDataResult = + | { readonly diagnostic: PermissionDiagnostic } + | { + readonly key: string + readonly metadata: PermissionMetadata + } + +function validMarkerValue(value: JsonObject): boolean { + const validTags = + value.tags === undefined || + (Array.isArray(value.tags) && + value.tags.every((tag) => typeof tag === 'string')) + const validAnnotations = + value.annotations === undefined || + (value.annotations !== null && + !Array.isArray(value.annotations) && + typeof value.annotations === 'object') + + return ( + typeof value.key === 'string' && + (value.title === undefined || typeof value.title === 'string') && + (value.description === undefined || + typeof value.description === 'string') && + validTags && + validAnnotations + ) +} + +function readObjectMarker( + marker: ObjectExpression, + source: string, + file: string +): MarkerDataResult { + const seen = new Set() + for (const property of marker.properties) { + if ( + property.type === 'SpreadElement' || + property.kind !== 'init' || + property.method || + property.shorthand + ) { + return { + diagnostic: diagnosticAt(source, file, property.start, { + code: 'dynamic-value', + message: + 'permission() metadata must use static object properties without spreads.', + }), + } + } + + const name = propertyName(property) + if (name === undefined || !MARKER_PROPERTIES.has(name) || seen.has(name)) { + return { + diagnostic: diagnosticAt(source, file, property.start, { + code: 'invalid-marker', + message: + 'permission() contains an unknown, computed, or duplicate property.', + }), + } + } + seen.add(name) + } + + const staticMarker = readStaticJson(marker) + if (!staticMarker.ok) { + return { + diagnostic: diagnosticAt(source, file, marker.start, { + code: 'dynamic-value', + message: + 'permission() keys and metadata must be statically analyzable JSON values.', + }), + } + } + + const value = staticMarker.value as JsonObject + if (!validMarkerValue(value)) { + return { + diagnostic: diagnosticAt(source, file, marker.start, { + code: 'invalid-marker', + message: + 'permission() requires a static string key and valid metadata values.', + }), + } + } + + return { + key: value.key as string, + metadata: { + ...(typeof value.title === 'string' ? { title: value.title } : {}), + ...(typeof value.description === 'string' + ? { description: value.description } + : {}), + ...(Array.isArray(value.tags) + ? { tags: value.tags as readonly string[] } + : {}), + ...(value.annotations === undefined + ? {} + : { annotations: value.annotations as JsonObject }), + }, + } +} + +function readMarkerData( + marker: Expression, + source: string, + file: string +): MarkerDataResult { + if (marker.type === 'Literal' && typeof marker.value === 'string') { + return { key: marker.value, metadata: {} } + } + + if (marker.type === 'TemplateLiteral' && marker.expressions.length === 0) { + return { + key: marker.quasis[0]?.value.cooked ?? marker.quasis[0]?.value.raw ?? '', + metadata: {}, + } + } + + if (marker.type === 'ObjectExpression') { + return readObjectMarker(marker, source, file) + } + + return { + diagnostic: diagnosticAt(source, file, marker.start, { + code: 'dynamic-value', + message: 'permission() keys must be static string literals.', + }), + } +} + +function readMarker( + call: CallExpression, + source: string, + file: string +): + | { readonly diagnostic: PermissionDiagnostic } + | { readonly permission: ExtractedPermission } { + const argument: Argument | undefined = call.arguments[0] + if ( + call.arguments.length !== 1 || + argument === undefined || + argument.type === 'SpreadElement' + ) { + return { + diagnostic: diagnosticAt(source, file, call.start, { + code: 'invalid-marker', + message: 'permission() requires exactly one static argument.', + }), + } + } + + const marker = unwrapExpression(argument) + const data = readMarkerData(marker, source, file) + if ('diagnostic' in data) { + return data + } + + if ( + data.key.length === 0 || + data.key.split('.').some((segment) => segment.length === 0) + ) { + return { + diagnostic: diagnosticAt(source, file, marker.start, { + code: 'invalid-permission-key', + message: + 'Permission keys must contain non-empty dot-separated segments.', + }), + } + } + + return { + permission: { + key: data.key, + metadata: data.metadata, + reference: { + file, + ...lineAndColumn(source, call.start), + }, + }, + } +} + +function isDirectMarkerCall( + call: CallExpression, + directNames: ReadonlySet +): boolean { + const callee = unwrapExpression(call.callee) + return callee.type === 'Identifier' && directNames.has(callee.name) +} + +function isNamespaceMarkerCall( + call: CallExpression, + namespaceNames: ReadonlySet +): boolean { + const callee = unwrapExpression(call.callee) + if ( + callee.type !== 'MemberExpression' || + callee.computed || + callee.object.type !== 'Identifier' + ) { + return false + } + + return ( + namespaceNames.has(callee.object.name) && + callee.property.name === 'permission' + ) +} + +export async function parsePermissionFile( + file: string, + source: string +): Promise { + const { parseSync, Visitor } = await importOxcParser() + const result = parseSync(file, source, { + astType: 'ts', + preserveParens: true, + showSemanticErrors: true, + sourceType: 'unambiguous', + }) + const diagnostics: PermissionDiagnostic[] = result.errors + .filter((error) => String(error.severity) === 'Error') + .map((error) => { + const label = error.labels[0] + return diagnosticAt(source, file, label?.start ?? 0, { + code: 'parse-error', + message: error.message, + }) + }) + + if (diagnostics.length > 0) { + return { diagnostics, permissions: [] } + } + + const directNames = new Set() + const namespaceNames = new Set() + const importBindings = new Set() + const importBindingStarts = new Set() + + for (const declaration of result.module.staticImports) { + if (declaration.moduleRequest.value !== 'permix') { + continue + } + + for (const entry of declaration.entries) { + if (entry.isType) { + continue + } + + const importKind = String(entry.importName.kind) + if (importKind === 'Name' && entry.importName.name === 'permission') { + directNames.add(entry.localName.value) + importBindings.add(entry.localName.value) + importBindingStarts.add(entry.localName.start) + } else if (importKind === 'NamespaceObject') { + namespaceNames.add(entry.localName.value) + importBindings.add(entry.localName.value) + importBindingStarts.add(entry.localName.start) + } + } + } + + if (importBindings.size === 0) { + return { diagnostics: [], permissions: [] } + } + + const permissions: ExtractedPermission[] = [] + const shadowedBindings: { + readonly name: string + readonly start: number + }[] = [] + + function checkBinding(pattern: BindingPattern): void { + if (pattern.type === 'Identifier') { + if (importBindings.has(pattern.name)) { + shadowedBindings.push({ + name: pattern.name, + start: pattern.start, + }) + } + return + } + + if (pattern.type === 'AssignmentPattern') { + checkBinding(pattern.left) + return + } + + if (pattern.type === 'ArrayPattern') { + for (const element of pattern.elements) { + if (element === null) { + continue + } + if (element.type === 'RestElement') { + checkBinding(element.argument) + } else { + checkBinding(element) + } + } + return + } + + for (const property of pattern.properties) { + if (property.type === 'RestElement') { + checkBinding(property.argument) + } else { + checkBinding(property.value) + } + } + } + + function checkParameter(parameter: ParamPattern): void { + if (parameter.type === 'TSParameterProperty') { + checkBinding(parameter.parameter) + } else if (parameter.type === 'RestElement') { + checkBinding(parameter.argument) + } else { + checkBinding(parameter) + } + } + + new Visitor({ + ArrowFunctionExpression(fn) { + for (const parameter of fn.params) { + checkParameter(parameter) + } + }, + FunctionDeclaration(fn) { + if (fn.id !== null && !importBindingStarts.has(fn.id.start)) { + checkBinding(fn.id) + } + for (const parameter of fn.params) { + checkParameter(parameter) + } + }, + FunctionExpression(fn) { + if (fn.id !== null && !importBindingStarts.has(fn.id.start)) { + checkBinding(fn.id) + } + for (const parameter of fn.params) { + checkParameter(parameter) + } + }, + VariableDeclarator(declaration) { + if (!importBindingStarts.has(declaration.id.start)) { + checkBinding(declaration.id) + } + }, + CatchClause(clause) { + if (clause.param !== null) { + checkBinding(clause.param) + } + }, + ClassDeclaration(declaration) { + if ( + declaration.id !== null && + !importBindingStarts.has(declaration.id.start) + ) { + checkBinding(declaration.id) + } + }, + ClassExpression(expression) { + if ( + expression.id !== null && + !importBindingStarts.has(expression.id.start) + ) { + checkBinding(expression.id) + } + }, + TSDeclareFunction(fn) { + if (fn.id !== null && !importBindingStarts.has(fn.id.start)) { + checkBinding(fn.id) + } + for (const parameter of fn.params) { + checkParameter(parameter) + } + }, + TSEmptyBodyFunctionExpression(fn) { + if (fn.id !== null && !importBindingStarts.has(fn.id.start)) { + checkBinding(fn.id) + } + for (const parameter of fn.params) { + checkParameter(parameter) + } + }, + CallExpression(call) { + if ( + !isDirectMarkerCall(call, directNames) && + !isNamespaceMarkerCall(call, namespaceNames) + ) { + return + } + + const extracted = readMarker(call, source, file) + if ('diagnostic' in extracted) { + diagnostics.push(extracted.diagnostic) + } else { + permissions.push(extracted.permission) + } + }, + }).visit(result.program) + + const shadowedBinding = shadowedBindings[0] + if (shadowedBinding !== undefined) { + diagnostics.push( + diagnosticAt(source, file, shadowedBinding.start, { + code: 'marker-shadowed', + message: `The imported permission marker "${shadowedBinding.name}" is shadowed by a local binding.`, + }) + ) + } + + return { + diagnostics, + permissions: diagnostics.length === 0 ? permissions : [], + } +} diff --git a/permix/src/extractor/standard-schema.test.ts b/permix/src/extractor/standard-schema.test.ts new file mode 100644 index 00000000..6f48ee2d --- /dev/null +++ b/permix/src/extractor/standard-schema.test.ts @@ -0,0 +1,83 @@ +import { type } from 'arktype' +import { Schema } from 'effect' +import * as v from 'valibot' +import { describe, expect, expectTypeOf, it } from 'vitest' +import { z } from 'zod' + +import { action, createPermissionOverlay, createPermix } from '../core' +import type { ApplyPermissionOverlay } from '../core' +import { renderPermissionCatalog } from './generate' + +const extractedDefinition = { + resource: ['arktype', 'effect', 'valibot', 'zod'], +} as const + +const zodSchema = z + .object({ id: z.string() }) + .transform(({ id }) => ({ resourceId: id })) +const valibotSchema = v.object({ id: v.string() }) +const arktypeSchema = type({ id: 'string' }) +const effectSchema = Schema.standardSchemaV1( + Schema.Struct({ id: Schema.String }) +) + +describe('generated definition Standard Schema interop', () => { + it('retains schema output and required-data types through an overlay', () => { + const defineOverlay = createPermissionOverlay() + const overlay = defineOverlay({ + resource: [ + action('arktype', arktypeSchema), + action('effect', effectSchema), + action('valibot', valibotSchema), + action('zod', zodSchema, { required: true }), + ], + }) + type Definition = ApplyPermissionOverlay< + typeof extractedDefinition, + typeof overlay + > + const permix = createPermix().setup({ + resource: { + arktype: (data) => { + expectTypeOf(data).toEqualTypeOf<{ id: string } | undefined>() + return true + }, + effect: (data) => { + expectTypeOf(data).toEqualTypeOf< + { readonly id: string } | undefined + >() + return true + }, + valibot: (data) => { + expectTypeOf(data).toEqualTypeOf<{ id: string } | undefined>() + return true + }, + zod: (data) => { + expectTypeOf(data).toEqualTypeOf<{ + resourceId: string + }>() + return true + }, + }, + }) + + expect(permix.check('resource.zod', { resourceId: 'resource-1' })).toBe( + true + ) + }) + + it('keeps validator objects out of the JSON catalog', () => { + const json = renderPermissionCatalog({ + schemaVersion: 1, + permissions: [ + { + key: 'resource.zod', + references: [], + }, + ], + }) + + expect(json).not.toContain('"schema":') + expect(json).not.toContain('"validator":') + }) +}) diff --git a/permix/src/extractor/types.ts b/permix/src/extractor/types.ts new file mode 100644 index 00000000..dcc8ad31 --- /dev/null +++ b/permix/src/extractor/types.ts @@ -0,0 +1,55 @@ +import type { PermissionMetadata } from '../core/permission' +import type { PermissionFileCache } from './extract' + +export const PERMISSION_CATALOG_SCHEMA_VERSION = 1 as const + +export interface PermissionReference { + readonly file: string + readonly line: number + readonly column: number +} + +export interface PermissionCatalogEntry extends PermissionMetadata { + readonly key: string + readonly references: readonly PermissionReference[] +} + +export interface PermissionCatalog { + readonly schemaVersion: typeof PERMISSION_CATALOG_SCHEMA_VERSION + readonly permissions: readonly PermissionCatalogEntry[] +} + +export type PermissionMetadataConfig = Readonly< + Record +> + +export type PermissionDiagnosticCode = + | 'conflicting-metadata' + | 'dynamic-value' + | 'invalid-marker' + | 'invalid-permission-key' + | 'marker-shadowed' + | 'parse-error' + | 'stale-metadata' + +export interface PermissionDiagnostic { + readonly code: PermissionDiagnosticCode + readonly message: string + readonly file: string + readonly line?: number + readonly column?: number +} + +export interface ExtractPermissionsOptions { + readonly cwd?: string + readonly include?: readonly string[] + readonly exclude?: readonly string[] + readonly metadata?: PermissionMetadataConfig + readonly cache?: PermissionFileCache + readonly force?: boolean +} + +export interface GeneratePermissionsOptions extends ExtractPermissionsOptions { + readonly catalogOutput?: string + readonly moduleOutput?: string +} diff --git a/permix/src/extractor/validate.test.ts b/permix/src/extractor/validate.test.ts new file mode 100644 index 00000000..da0f500c --- /dev/null +++ b/permix/src/extractor/validate.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' + +import { validatePermissionCoverage } from './validate' + +describe(validatePermissionCoverage, () => { + it('reports unknown provider keys and uncovered catalog keys', () => { + const result = validatePermissionCoverage( + ['projects.read', 'projects.update'], + new Set(['projects.read', 'projects.delete']) + ) + + expect(result).toStrictEqual({ + valid: false, + unknown: ['projects.delete'], + uncovered: ['projects.update'], + }) + }) + + it('accepts versioned catalogs as key sources', () => { + const catalog = { + schemaVersion: 1, + permissions: [ + { + key: 'projects.read', + references: [], + }, + ], + } as const + + expect( + validatePermissionCoverage(catalog, ['projects.read']) + ).toStrictEqual({ + valid: true, + unknown: [], + uncovered: [], + }) + }) +}) diff --git a/permix/src/extractor/validate.ts b/permix/src/extractor/validate.ts new file mode 100644 index 00000000..9a79b9a3 --- /dev/null +++ b/permix/src/extractor/validate.ts @@ -0,0 +1,44 @@ +import type { PermissionCatalog } from './types' + +export interface PermissionCoverageResult { + readonly valid: boolean + readonly unknown: readonly string[] + readonly uncovered: readonly string[] +} + +export type PermissionKeySource = PermissionCatalog | Iterable + +function permissionKeys(source: PermissionKeySource): Set { + if ( + typeof source === 'object' && + source !== null && + 'permissions' in source + ) { + return new Set(source.permissions.map(({ key }) => key)) + } + + return new Set(source) +} + +/** + * Compares provider policy or operation keys with an extracted catalog. + */ +export function validatePermissionCoverage( + catalog: PermissionKeySource, + providerManifest: PermissionKeySource +): PermissionCoverageResult { + const catalogKeys = permissionKeys(catalog) + const providerKeys = permissionKeys(providerManifest) + const unknown = [...providerKeys] + .filter((key) => !catalogKeys.has(key)) + .toSorted() + const uncovered = [...catalogKeys] + .filter((key) => !providerKeys.has(key)) + .toSorted() + + return { + valid: unknown.length === 0 && uncovered.length === 0, + unknown, + uncovered, + } +} diff --git a/permix/src/extractor/watch.test.ts b/permix/src/extractor/watch.test.ts new file mode 100644 index 00000000..24859fc5 --- /dev/null +++ b/permix/src/extractor/watch.test.ts @@ -0,0 +1,148 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' + +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { PermissionWatchEvent } from './watch' +import { watchPermissions } from './watch' + +const temporaryDirectories: string[] = [] + +describe(watchPermissions, () => { + afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })) + ) + }) + + it('regenerates after add, edit, and delete events', async () => { + const cwd = await mkdtemp(path.join(tmpdir(), 'permix-watch-')) + temporaryDirectories.push(cwd) + const sourceFile = path.join(cwd, 'permissions.ts') + await writeFile( + sourceFile, + `import { permission } from 'permix' +permission('projects.read') +` + ) + const events: PermissionWatchEvent[] = [] + const watcher = await watchPermissions({ cwd, debounceMs: 10 }, (event) => + events.push(event) + ) + + try { + await writeFile( + sourceFile, + `import { permission } from 'permix' +permission('projects.update') +` + ) + + await vi.waitFor( + () => { + const generated = events.filter((event) => event.type === 'generated') + expect(generated.at(-1)?.result.catalog.permissions[0]?.key).toBe( + 'projects.update' + ) + }, + { timeout: 3000 } + ) + + await rm(sourceFile) + await vi.waitFor( + () => { + const generated = events.filter((event) => event.type === 'generated') + expect(generated.at(-1)?.result.catalog.permissions).toStrictEqual([]) + }, + { timeout: 3000 } + ) + } finally { + await watcher.close() + } + }) + + it('keeps cached files across edits, deletes, and renames', async () => { + const cwd = await mkdtemp(path.join(tmpdir(), 'permix-watch-incr-')) + temporaryDirectories.push(cwd) + const readFile = path.join(cwd, 'read.ts') + const updateFile = path.join(cwd, 'update.ts') + await writeFile( + readFile, + `import { permission } from 'permix' +permission('projects.read') +` + ) + await writeFile( + updateFile, + `import { permission } from 'permix' +permission('projects.update') +` + ) + + const events: PermissionWatchEvent[] = [] + const watcher = await watchPermissions({ cwd, debounceMs: 10 }, (event) => + events.push(event) + ) + + try { + const initial = events.at(-1) + expect(initial?.type).toBe('generated') + if (initial?.type !== 'generated') { + throw new Error('expected an initial generated event') + } + expect( + initial.result.catalog.permissions.map((item) => item.key) + ).toStrictEqual(['projects.read', 'projects.update']) + + await writeFile( + updateFile, + `import { permission } from 'permix' +permission('projects.publish') +` + ) + await vi.waitFor( + () => { + const generated = events.filter((event) => event.type === 'generated') + expect( + generated.at(-1)?.result.catalog.permissions.map((item) => item.key) + ).toStrictEqual(['projects.publish', 'projects.read']) + }, + { timeout: 3000 } + ) + + const renamed = path.join(cwd, 'renamed-read.ts') + await writeFile( + renamed, + `import { permission } from 'permix' +permission('projects.read') +` + ) + await rm(readFile) + await vi.waitFor( + () => { + const generated = events.filter((event) => event.type === 'generated') + expect( + generated.at(-1)?.result.catalog.permissions.map((item) => item.key) + ).toStrictEqual(['projects.publish', 'projects.read']) + }, + { timeout: 3000 } + ) + + await rm(updateFile) + await vi.waitFor( + () => { + const generated = events.filter((event) => event.type === 'generated') + expect( + generated.at(-1)?.result.catalog.permissions.map((item) => item.key) + ).toStrictEqual(['projects.read']) + }, + { timeout: 3000 } + ) + } finally { + await watcher.close() + } + }) +}) diff --git a/permix/src/extractor/watch.ts b/permix/src/extractor/watch.ts new file mode 100644 index 00000000..2526ce77 --- /dev/null +++ b/permix/src/extractor/watch.ts @@ -0,0 +1,156 @@ +import path from 'node:path' + +import { importChokidar } from './deps' +import { PermissionExtractionError } from './error' +import { createPermissionFileCache } from './extract' +import { generatePermissions } from './generate' +import type { GeneratePermissionsResult } from './generate' +import type { GeneratePermissionsOptions } from './types' + +export interface WatchPermissionsOptions extends GeneratePermissionsOptions { + readonly debounceMs?: number +} + +export type PermissionWatchEvent = + | { + readonly type: 'error' + readonly error: Error + } + | { + readonly type: 'generated' + readonly result: GeneratePermissionsResult + } + +export interface PermissionWatcher { + readonly close: () => Promise +} + +export type PermissionWatchListener = (event: PermissionWatchEvent) => void + +const WATCH_IGNORES = [ + /[/\\]\.git[/\\]/, + /[/\\]\.next[/\\]/, + /[/\\]\.nuxt[/\\]/, + /[/\\]\.output[/\\]/, + /[/\\]\.permix[/\\]/, + /[/\\]coverage[/\\]/, + /[/\\]dist[/\\]/, + /[/\\]node_modules[/\\]/, +] + +/** + * Watches a source root and regenerates artifacts from an incremental parse cache. + */ +export async function watchPermissions( + options: WatchPermissionsOptions = {}, + listener?: PermissionWatchListener +): Promise { + const cwd = path.resolve(options.cwd ?? process.cwd()) + const debounceMs = options.debounceMs ?? 50 + const cache = options.cache ?? createPermissionFileCache() + let timer: ReturnType | undefined + let running: Promise | undefined + let rerun = false + let closed = false + + async function generate(force = false): Promise { + if (closed) { + return + } + + if (running !== undefined) { + rerun = true + await running + return + } + + running = generatePermissions({ + ...options, + cwd, + cache, + ...(force ? { force: true } : {}), + }) + .then((result) => { + listener?.({ type: 'generated', result }) + }) + .catch((error: unknown) => { + listener?.({ + type: 'error', + error: + error instanceof Error + ? error + : new PermissionExtractionError([ + { + code: 'invalid-marker', + file: '', + message: String(error), + }, + ]), + }) + }) + .finally(async () => { + running = undefined + if (rerun) { + rerun = false + await generate() + } + }) + + await running + } + + const initialResult = await generatePermissions({ + ...options, + cwd, + cache, + force: true, + }) + listener?.({ type: 'generated', result: initialResult }) + + const { watch } = await importChokidar() + const watcher = watch(cwd, { + ignoreInitial: true, + ignored: WATCH_IGNORES, + }) + + await new Promise((resolve, reject) => { + const handleReady = () => { + watcher.off('error', handleError) + resolve() + } + const handleError = (error: unknown) => { + watcher.off('ready', handleReady) + reject(error instanceof Error ? error : new Error(String(error))) + } + + watcher.once('ready', handleReady) + watcher.once('error', handleError) + }) + + watcher.on('all', (event, filePath) => { + if (typeof filePath === 'string' && filePath.length > 0) { + cache.delete(path.resolve(filePath)) + } + if (event === 'unlinkDir') { + cache.clear() + } + if (timer !== undefined) { + clearTimeout(timer) + } + timer = setTimeout(() => { + timer = undefined + void generate(options.force === true) + }, debounceMs) + }) + + return { + async close() { + closed = true + if (timer !== undefined) { + clearTimeout(timer) + } + await watcher.close() + await running + }, + } +} diff --git a/permix/src/fastify/permix.test.ts b/permix/src/fastify/permix.test.ts index 26f93dbc..fcd7023c 100644 --- a/permix/src/fastify/permix.test.ts +++ b/permix/src/fastify/permix.test.ts @@ -373,6 +373,35 @@ describe('checkMiddleware without setupMiddleware', () => { }) }) +describe('fail closed after deny', () => { + it('should not run the route body when onForbidden is a no-op', async () => { + const permix = createPermix({ + onForbidden: () => undefined, + }) + + const app = Fastify() + + await app.register( + permix.setupMiddleware(() => ({ + post: { create: false, read: false, update: false }, + user: { delete: false }, + })) + ) + + app.post( + '/posts', + { preHandler: permix.checkMiddleware('post.create') }, + (req, reply) => { + reply.send({ success: true }) + } + ) + + const response = await app.inject({ method: 'POST', url: '/posts' }) + expect(response.statusCode).toBe(403) + expect(response.json()).toStrictEqual({ error: 'Forbidden' }) + }) +}) + describe('key exposure', () => { it('should expose the key on the factory return', () => { const permix = diff --git a/permix/src/fastify/permix.ts b/permix/src/fastify/permix.ts index 700a566d..c07f59a3 100644 --- a/permix/src/fastify/permix.ts +++ b/permix/src/fastify/permix.ts @@ -8,11 +8,11 @@ import fp from 'fastify-plugin' import type { Permix as PermixCore } from '../core' import { - createCheckContext, createHooks, createPermix as createPermixCore, createTemplate, PermixNotFoundError, + withDenialReasons, } from '../core' import type { CheckArgs, CheckContext } from '../core/check' import type { Definition } from '../core/definitions' @@ -83,7 +83,7 @@ function buildPermix( typeof callbackOrRules === 'function' ? await callbackOrRules({ request, reply }) : callbackOrRules - const instance = createPermixCore(rules) + const instance = createPermixCore().setup(rules) instance.hook('check', (context) => { hooks.callHook('check', context) }) @@ -109,7 +109,14 @@ function buildPermix( const allowed = permix.check(...args) if (!allowed) { - await onForbidden({ request, reply, ...createCheckContext(...args) }) + await onForbidden({ + request, + reply, + ...withDenialReasons(permix, args), + }) + if (!reply.sent) { + reply.status(403).send({ error: 'Forbidden' }) + } } } diff --git a/permix/src/hono/permix.ts b/permix/src/hono/permix.ts index 049bcc5f..40ec9a55 100644 --- a/permix/src/hono/permix.ts +++ b/permix/src/hono/permix.ts @@ -2,16 +2,11 @@ import type { Context, MiddlewareHandler } from 'hono' import { createMiddleware } from 'hono/factory' import type { Permix as PermixCore } from '../core' -import { - createCheckContext, - createHooks, - createPermix as createPermixCore, - createTemplate, - PermixNotFoundError, -} from '../core' +import { PermixNotFoundError, withDenialReasons } from '../core' import type { CheckArgs, CheckContext } from '../core/check' import type { Definition } from '../core/definitions' -import type { PermixHooks, Rules, RulesPaths } from '../core/permix' +import type { Rules } from '../core/permix' +import { createRequestKernel, withContextKey } from '../server/kernel' import type { MaybePromise } from '../utils' export interface MiddlewareContext { @@ -34,93 +29,6 @@ function keyToString(key: string | symbol): string { return key as string } -function buildPermix( - resolveKey: () => string | symbol, - options: PermixOptions = {} -) { - const onForbidden = - options.onForbidden ?? (({ c }) => c.json({ error: 'Forbidden' }, 403)) - - const hooks = createHooks>() - - function get(c: Context): PermixCore | null { - const instance = c.get(keyToString(resolveKey())) as - | PermixCore - | undefined - return instance ?? null - } - - function getOrThrow(c: Context): PermixCore { - const instance = get(c) - if (!instance) { - throw new PermixNotFoundError(resolveKey()) - } - return instance - } - - function setupMiddleware( - callbackOrRules: - | ((context: MiddlewareContext) => MaybePromise>) - | Rules - ): MiddlewareHandler { - return createMiddleware(async (c, next) => { - const rules = - typeof callbackOrRules === 'function' - ? await callbackOrRules({ c }) - : callbackOrRules - const instance = createPermixCore(rules) - instance.hook('check', (context) => { - hooks.callHook('check', context) - }) - c.set(keyToString(resolveKey()), instance) - await next() - }) - } - - const checkMiddleware: (...args: CheckArgs) => MiddlewareHandler = ( - ...args - ) => - createMiddleware(async (c, next) => { - const permix = get(c) - - if (!permix) { - throw new PermixNotFoundError(resolveKey()) - } - - const allowed = permix.check(...args) - - if (!allowed) { - return await onForbidden({ c, ...createCheckContext(...args) }) - } - - await next() - }) - - function getRules(c: Context): Rules | null { - return get(c)?.getRules() ?? null - } - - function template(rules: Rules | ((param: T) => Rules)) { - return createTemplate(rules) - } - - return { - setupMiddleware, - checkMiddleware, - template, - get, - getOrThrow, - getRules, - hook: hooks.hook, - hookOnce: hooks.hookOnce, - get key() { - return resolveKey() - }, - $inferDefinition: undefined as unknown as D, - $inferPath: undefined as unknown as RulesPaths, - } -} - /** * Create a middleware factory that wires Permix into Hono routes. * @@ -149,14 +57,67 @@ function buildPermix( export function createPermix( options: PermixOptions = {} ) { - let key: string | symbol = Symbol('permix') - const permix = buildPermix(() => key, options) + const onForbidden = + options.onForbidden ?? (({ c }) => c.json({ error: 'Forbidden' }, 403)) + + return withContextKey((resolveKey) => { + const kernel = createRequestKernel(resolveKey, { + get: (c) => c.get(keyToString(resolveKey())) as PermixCore | undefined, + set: (c, instance) => { + c.set(keyToString(resolveKey()), instance) + }, + }) + + function setupMiddleware( + callbackOrRules: + | ((context: MiddlewareContext) => MaybePromise>) + | Rules + ): MiddlewareHandler { + return createMiddleware(async (c, next) => { + const rules = + typeof callbackOrRules === 'function' + ? await callbackOrRules({ c }) + : callbackOrRules + kernel.attach(c, rules) + await next() + }) + } + + const checkMiddleware: (...args: CheckArgs) => MiddlewareHandler = ( + ...args + ) => + createMiddleware(async (c, next) => { + const permix = kernel.get(c) + + if (!permix) { + throw new PermixNotFoundError(resolveKey()) + } + + const allowed = permix.check(...args) - return Object.assign(permix, { - contextKey(newKey: string | symbol) { - key = newKey - return permix - }, + if (!allowed) { + return await onForbidden({ c, ...withDenialReasons(permix, args) }) + } + + // oxlint-disable-next-line typescript/no-confusing-void-expression -- Hono's next() is Promise + return await next() + }) + + return { + setupMiddleware, + checkMiddleware, + template: kernel.template, + get: kernel.get, + getOrThrow: kernel.getOrThrow, + getRules: kernel.getRules, + hook: kernel.hook, + hookOnce: kernel.hookOnce, + get key() { + return kernel.key + }, + $inferDefinition: kernel.$inferDefinition, + $inferPath: kernel.$inferPath, + } }) } diff --git a/permix/src/nest/index.ts b/permix/src/nest/index.ts new file mode 100644 index 00000000..60dafabb --- /dev/null +++ b/permix/src/nest/index.ts @@ -0,0 +1 @@ +export * from './permix' diff --git a/permix/src/nest/permix.test.ts b/permix/src/nest/permix.test.ts new file mode 100644 index 00000000..27fa3992 --- /dev/null +++ b/permix/src/nest/permix.test.ts @@ -0,0 +1,643 @@ +import 'reflect-metadata' +import type { INestApplication, Type } from '@nestjs/common' +import { + Controller, + ForbiddenException, + Get, + Module, + Post, + Req, +} from '@nestjs/common' +import { APP_GUARD } from '@nestjs/core' +import { Test } from '@nestjs/testing' +import request from 'supertest' +import { afterEach, describe, expect, it } from 'vitest' + +import type { ValidateDefinition } from '../core' +import { createPermix } from './permix' + +interface PostEntity { + id: string + authorId: string +} + +type PermissionsDefinition = ValidateDefinition<{ + post: ['create', 'read', 'update'] + user: ['delete'] +}> + +type PostWithData = ValidateDefinition<{ + post: [{ name: 'create'; type: PostEntity }] +}> + +const denied = { + post: { create: false, read: false, update: false }, + user: { delete: false }, +} + +describe('permix/nest', () => { + let app: INestApplication | undefined + + afterEach(async () => { + if (app) { + await app.close() + app = undefined + } + }) + + async function createApp(module: Type): Promise { + const moduleRef = await Test.createTestingModule({ + imports: [module], + }).compile() + app = moduleRef.createNestApplication({ logger: false }) + await app.init() + return app + } + + describe(createPermix, () => { + const permix = createPermix() + + it('should throw ts error', () => { + // @ts-expect-error path does not exist + permix.Check('post.delete') + }) + + it('should allow access when permission is granted', async () => { + @Controller() + class PostsController { + @Post('posts') + @permix.Check('post.create') + create() { + return { success: true } + } + } + + @Module({ + controllers: [PostsController], + providers: [ + { + provide: APP_GUARD, + useValue: permix.guard({ + post: { create: true, read: false, update: false }, + user: { delete: false }, + }), + }, + ], + }) + class AppModule {} + + const nestApp = await createApp(AppModule) + const response = await request(nestApp.getHttpServer()) + .post('/posts') + .send({ title: 'Test Post' }) + + expect(response.status).toBe(201) + expect(response.body).toStrictEqual({ success: true }) + }) + + it('should deny access when permission is not granted', async () => { + @Controller() + class PostsController { + @Post('posts') + @permix.Check('post.create') + create() { + return { success: true } + } + } + + @Module({ + controllers: [PostsController], + providers: [{ provide: APP_GUARD, useValue: permix.guard(denied) }], + }) + class AppModule {} + + const nestApp = await createApp(AppModule) + const response = await request(nestApp.getHttpServer()) + .post('/posts') + .send({ title: 'Test Post' }) + + expect(response.status).toBe(403) + expect(response.body).toStrictEqual({ error: 'Forbidden' }) + }) + + it('should work with custom error handler', async () => { + const permix = createPermix({ + onForbidden: () => { + throw new ForbiddenException({ error: 'Custom error' }) + }, + }) + + @Controller() + class PostsController { + @Post('posts') + @permix.Check('post.create') + create() { + return { success: true } + } + } + + @Module({ + controllers: [PostsController], + providers: [{ provide: APP_GUARD, useValue: permix.guard(denied) }], + }) + class AppModule {} + + const nestApp = await createApp(AppModule) + const response = await request(nestApp.getHttpServer()) + .post('/posts') + .send({ title: 'Test Post' }) + + expect(response.status).toBe(403) + expect(response.body).toStrictEqual({ error: 'Custom error' }) + }) + + it('should work with custom error and params', async () => { + const permix = createPermix({ + onForbidden: ({ path }) => { + throw new ForbiddenException({ + error: `You do not have permission for ${path}`, + }) + }, + }) + + @Controller() + class PostsController { + @Post('posts') + @permix.Check('post.create') + create() { + return { success: true } + } + } + + @Module({ + controllers: [PostsController], + providers: [{ provide: APP_GUARD, useValue: permix.guard(denied) }], + }) + class AppModule {} + + const nestApp = await createApp(AppModule) + const response = await request(nestApp.getHttpServer()) + .post('/posts') + .send({ title: 'Test Post' }) + + expect(response.status).toBe(403) + expect(response.body).toStrictEqual({ + error: 'You do not have permission for post.create', + }) + }) + + it('should pass data through to a rule callback', async () => { + const permix = createPermix() + + @Controller() + class PostsController { + @Post('posts') + @permix.Check('post.create', { id: 'a', authorId: '1' }) + create() { + return { success: true } + } + } + + @Module({ + controllers: [PostsController], + providers: [ + { + provide: APP_GUARD, + useValue: permix.guard({ + post: { + create: (post) => post?.authorId === '1', + }, + }), + }, + ], + }) + class AppModule {} + + const nestApp = await createApp(AppModule) + const response = await request(nestApp.getHttpServer()) + .post('/posts') + .send({ title: 'Test Post' }) + + expect(response.status).toBe(201) + expect(response.body).toStrictEqual({ success: true }) + }) + + it('should work with checker callback form', async () => { + const permix = createPermix() + + @Controller() + class PostsController { + @Post('posts') + @permix.Check((c) => c('post.create') && c('user.delete')) + create() { + return { success: true } + } + } + + @Module({ + controllers: [PostsController], + providers: [ + { + provide: APP_GUARD, + useValue: permix.guard({ + post: { create: true, read: true, update: false }, + user: { delete: true }, + }), + }, + ], + }) + class AppModule {} + + const nestApp = await createApp(AppModule) + const response = await request(nestApp.getHttpServer()) + .post('/posts') + .send({ title: 'Test Post' }) + + expect(response.status).toBe(201) + expect(response.body).toStrictEqual({ success: true }) + }) + + it('should work with template', async () => { + const template = permix.template({ + post: { create: true, read: true, update: true }, + user: { delete: true }, + }) + + @Controller() + class PostsController { + @Post('posts') + @permix.Check('post.create') + create() { + return { success: true } + } + } + + @Module({ + controllers: [PostsController], + providers: [ + { provide: APP_GUARD, useValue: permix.guard(() => template()) }, + ], + }) + class AppModule {} + + const nestApp = await createApp(AppModule) + const response = await request(nestApp.getHttpServer()) + .post('/posts') + .send({ title: 'Test Post' }) + + expect(response.status).toBe(201) + expect(response.body).toStrictEqual({ success: true }) + }) + + it('should dehydrate permissions', async () => { + const template = permix.template({ + post: { create: true, read: false, update: true }, + user: { delete: false }, + }) + + @Controller() + class DehydrateController { + @Get('dehydrate') + dehydrate(@Req() req: { [key: PropertyKey]: unknown }) { + return permix.getOrThrow(req).dehydrate() + } + } + + @Module({ + controllers: [DehydrateController], + providers: [ + { provide: APP_GUARD, useValue: permix.guard(() => template()) }, + ], + }) + class AppModule {} + + const nestApp = await createApp(AppModule) + const response = await request(nestApp.getHttpServer()).get('/dehydrate') + + expect(response.status).toBe(200) + expect(response.body).toStrictEqual({ + post: { create: true, read: false, update: true }, + user: { delete: false }, + }) + }) + + it('should allow a handler without Check after setup', async () => { + @Controller() + class OpenController { + @Get('open') + open() { + return { ok: true } + } + } + + @Module({ + controllers: [OpenController], + providers: [{ provide: APP_GUARD, useValue: permix.guard(denied) }], + }) + class AppModule {} + + const nestApp = await createApp(AppModule) + const response = await request(nestApp.getHttpServer()).get('/open') + expect(response.status).toBe(200) + expect(response.body).toStrictEqual({ ok: true }) + }) + + it('should let two factories with different keys coexist on the same request', async () => { + const admin = createPermix().contextKey('admin') + const guest = createPermix().contextKey('guest') + + @Controller() + class DualController { + @Post('admin') + @admin.Check('post.create') + adminRoute() { + return { scope: 'admin' } + } + + @Post('guest') + @guest.Check('post.create') + guestRoute() { + return { scope: 'guest' } + } + } + + @Module({ + controllers: [DualController], + providers: [ + { + provide: APP_GUARD, + useValue: admin.guard({ + post: { create: true, read: true, update: true }, + user: { delete: true }, + }), + }, + { + provide: APP_GUARD, + useValue: guest.guard({ + post: { create: false, read: true, update: false }, + user: { delete: false }, + }), + }, + ], + }) + class AppModule {} + + const nestApp = await createApp(AppModule) + + const adminResponse = await request(nestApp.getHttpServer()).post( + '/admin' + ) + expect(adminResponse.status).toBe(201) + expect(adminResponse.body).toStrictEqual({ scope: 'admin' }) + + const guestResponse = await request(nestApp.getHttpServer()).post( + '/guest' + ) + expect(guestResponse.status).toBe(403) + expect(guestResponse.body).toStrictEqual({ error: 'Forbidden' }) + }) + + it('should default to a per-instance symbol so two factories without a key do not collide', async () => { + const first = createPermix() + const second = createPermix() + + @Controller() + class DualController { + @Post('first') + @first.Check('post.create') + firstRoute() { + return { ok: true } + } + + @Post('second') + @second.Check('post.create') + secondRoute() { + return { ok: true } + } + } + + @Module({ + controllers: [DualController], + providers: [ + { + provide: APP_GUARD, + useValue: first.guard({ + post: { create: true, read: true, update: true }, + user: { delete: true }, + }), + }, + { + provide: APP_GUARD, + useValue: second.guard({ + post: { create: false, read: false, update: false }, + user: { delete: false }, + }), + }, + ], + }) + class AppModule {} + + const nestApp = await createApp(AppModule) + + const firstResponse = await request(nestApp.getHttpServer()).post( + '/first' + ) + expect(firstResponse.status).toBe(201) + + const secondResponse = await request(nestApp.getHttpServer()).post( + '/second' + ) + expect(secondResponse.status).toBe(403) + }) + + it('should accept an explicit symbol key', async () => { + const key = Symbol('my-permix') + const permix = createPermix().contextKey(key) + + @Controller() + class ProbeController { + @Get('probe') + probe(@Req() req: { [key: PropertyKey]: unknown }) { + return { attached: Boolean(req[key]) } + } + } + + @Module({ + controllers: [ProbeController], + providers: [ + { + provide: APP_GUARD, + useValue: permix.guard({ + post: { create: true, read: true, update: true }, + user: { delete: true }, + }), + }, + ], + }) + class AppModule {} + + const nestApp = await createApp(AppModule) + const response = await request(nestApp.getHttpServer()).get('/probe') + expect(response.status).toBe(200) + expect(response.body).toStrictEqual({ attached: true }) + }) + + it('should honor a class-level Check decorator', async () => { + @Controller('posts') + @permix.Check('post.create') + class PostsController { + @Post() + create() { + return { success: true } + } + } + + @Module({ + controllers: [PostsController], + providers: [{ provide: APP_GUARD, useValue: permix.guard(denied) }], + }) + class AppModule {} + + const nestApp = await createApp(AppModule) + const response = await request(nestApp.getHttpServer()).post('/posts') + expect(response.status).toBe(403) + expect(response.body).toStrictEqual({ error: 'Forbidden' }) + }) + }) + + describe('get / getOrThrow', () => { + const permix = createPermix() + + it('should return null when the guard has not run', async () => { + @Controller() + class RootController { + @Get() + root(@Req() req: { [key: PropertyKey]: unknown }) { + return { result: permix.get(req) } + } + } + + @Module({ controllers: [RootController] }) + class AppModule {} + + const nestApp = await createApp(AppModule) + const response = await request(nestApp.getHttpServer()).get('/') + expect(response.status).toBe(200) + expect(response.body).toStrictEqual({ result: null }) + }) + + it('should return the instance when the guard has run', async () => { + @Controller() + class RootController { + @Get() + root(@Req() req: { [key: PropertyKey]: unknown }) { + const p = permix.getOrThrow(req) + return { hasCheck: typeof p.check === 'function' } + } + } + + @Module({ + controllers: [RootController], + providers: [ + { + provide: APP_GUARD, + useValue: permix.guard({ + post: { create: true, read: true, update: true }, + user: { delete: true }, + }), + }, + ], + }) + class AppModule {} + + const nestApp = await createApp(AppModule) + const response = await request(nestApp.getHttpServer()).get('/') + expect(response.status).toBe(200) + expect(response.body).toStrictEqual({ hasCheck: true }) + }) + + it('getOrThrow should throw PermixNotFoundError when missing', async () => { + @Controller() + class RootController { + @Get() + root(@Req() req: { [key: PropertyKey]: unknown }) { + permix.getOrThrow(req) + return { ok: true } + } + } + + @Module({ controllers: [RootController] }) + class AppModule {} + + const nestApp = await createApp(AppModule) + const response = await request(nestApp.getHttpServer()).get('/') + expect(response.status).toBe(500) + expect(response.body.statusCode).toBe(500) + }) + + it('getRules should return null when the guard has not run', async () => { + @Controller() + class RootController { + @Get() + root(@Req() req: { [key: PropertyKey]: unknown }) { + return { rules: permix.getRules(req) } + } + } + + @Module({ controllers: [RootController] }) + class AppModule {} + + const nestApp = await createApp(AppModule) + const response = await request(nestApp.getHttpServer()).get('/') + expect(response.status).toBe(200) + expect(response.body).toStrictEqual({ rules: null }) + }) + + it('getRules should return the current rules when the guard has run', async () => { + @Controller() + class RootController { + @Get() + root(@Req() req: { [key: PropertyKey]: unknown }) { + return { rules: permix.getRules(req) } + } + } + + @Module({ + controllers: [RootController], + providers: [ + { + provide: APP_GUARD, + useValue: permix.guard({ + post: { create: true, read: false, update: false }, + user: { delete: true }, + }), + }, + ], + }) + class AppModule {} + + const nestApp = await createApp(AppModule) + const response = await request(nestApp.getHttpServer()).get('/') + expect(response.status).toBe(200) + expect(response.body).toStrictEqual({ + rules: { + post: { create: true, read: false, update: false }, + user: { delete: true }, + }, + }) + }) + }) + + describe('key exposure', () => { + it('should expose the key on the factory return', () => { + const permix = + createPermix().contextKey('custom-key') + expect(permix.key).toBe('custom-key') + }) + + it('should expose a symbol key when using default', () => { + const permix = createPermix() + expect(permix.key).toBeTypeOf('symbol') + }) + }) +}) diff --git a/permix/src/nest/permix.ts b/permix/src/nest/permix.ts new file mode 100644 index 00000000..90d460af --- /dev/null +++ b/permix/src/nest/permix.ts @@ -0,0 +1,214 @@ +import type { + CanActivate, + CustomDecorator, + ExecutionContext, +} from '@nestjs/common' +import { ForbiddenException, SetMetadata } from '@nestjs/common' + +import type { Permix as PermixCore } from '../core' +import { + createHooks, + createPermix as createPermixCore, + createTemplate, + PermixNotFoundError, + withDenialReasons, +} from '../core' +import type { CheckArgs, CheckContext } from '../core/check' +import type { Definition } from '../core/definitions' +import type { PermixHooks, Rules, RulesPaths } from '../core/permix' +import type { MaybePromise } from '../utils' + +/** + * HTTP request object from `ExecutionContext.switchToHttp().getRequest()`. + * Compatible with both the Express and Fastify Nest adapters. + */ +export type NestHttpRequest = Record + +export interface GuardContext { + req: NestHttpRequest + context: ExecutionContext +} + +export interface PermixOptions { + /** + * Called when a `@Check` decorator denies the request. Defaults to throwing + * a Nest `ForbiddenException` with `{ error: 'Forbidden' }`. + */ + onForbidden?: (params: CheckContext & GuardContext) => MaybePromise +} + +function getRequest(context: ExecutionContext): NestHttpRequest { + return context.switchToHttp().getRequest() +} + +function readCheckArgs( + metadataKey: string | symbol, + context: ExecutionContext +): CheckArgs | undefined { + const handler = context.getHandler() + const classRef = context.getClass() + const fromHandler = Reflect.getMetadata(metadataKey, handler) as + | CheckArgs + | undefined + if (fromHandler) { + return fromHandler + } + return Reflect.getMetadata(metadataKey, classRef) as CheckArgs | undefined +} + +function buildPermix( + resolveKey: () => string | symbol, + options: PermixOptions = {} +) { + const checkMetadataKey = Symbol('permix:check') + const onForbidden = + options.onForbidden ?? + (() => { + throw new ForbiddenException({ error: 'Forbidden' }) + }) + + const hooks = createHooks>() + + function get(req: NestHttpRequest): PermixCore | null { + const instance = req[resolveKey()] as PermixCore | undefined + return instance ?? null + } + + function getOrThrow(req: NestHttpRequest): PermixCore { + const instance = get(req) + if (!instance) { + throw new PermixNotFoundError(resolveKey()) + } + return instance + } + + function attach(req: NestHttpRequest, rules: Rules): PermixCore { + const instance = createPermixCore().setup(rules) + instance.hook('check', (context) => { + hooks.callHook('check', context) + }) + req[resolveKey()] = instance + return instance + } + + /** + * Nest guard that always sets up a per-request Permix instance, then enforces + * `@Check(...)` when that decorator is present on the handler or controller. + * + * Register globally with `APP_GUARD`, or per-controller / per-route with + * `@UseGuards`. + */ + function guard( + callbackOrRules: + | ((context: GuardContext) => MaybePromise>) + | Rules + ): CanActivate { + return { + async canActivate(context) { + const req = getRequest(context) + const rules = + typeof callbackOrRules === 'function' + ? await callbackOrRules({ req, context }) + : callbackOrRules + const instance = attach(req, rules) + + const args = readCheckArgs(checkMetadataKey, context) + if (!args) { + return true + } + + const allowed = instance.check(...args) + if (allowed) { + return true + } + + await onForbidden({ + req, + context, + ...withDenialReasons(instance, args), + }) + return false + }, + } + } + + /** + * Method or class decorator that records the permission check for `guard()`. + */ + const Check: (...args: CheckArgs) => CustomDecorator = ( + ...args + ) => SetMetadata(checkMetadataKey, args) + + function getRules(req: NestHttpRequest): Rules | null { + return get(req)?.getRules() ?? null + } + + function template(rules: Rules | ((param: T) => Rules)) { + return createTemplate(rules) + } + + return { + guard, + Check, + template, + get, + getOrThrow, + getRules, + hook: hooks.hook, + hookOnce: hooks.hookOnce, + get key() { + return resolveKey() + }, + $inferDefinition: undefined as unknown as D, + $inferPath: undefined as unknown as RulesPaths, + } +} + +/** + * Create a guard factory that wires Permix into NestJS routes. + * + * Use `.contextKey('name')` to set a custom request key (defaults to a unique + * `Symbol('permix')`). + * + * @example + * ```ts + * import { APP_GUARD } from '@nestjs/core' + * import { createPermix } from 'permix/nest' + * + * const permix = createPermix<{ + * post: ['create', 'read'] + * }>() + * + * @Get() + * @permix.Check('post.read') + * findAll() {} + * + * // app.module.ts + * { + * provide: APP_GUARD, + * useValue: permix.guard(({ req }) => ({ + * post: { create: !!req.user, read: true }, + * })), + * } + * ``` + * + * @link https://permix.letstri.dev/docs/integrations/nest + */ +export function createPermix( + options: PermixOptions = {} +) { + let key: string | symbol = Symbol('permix') + const permix = buildPermix(() => key, options) + + return Object.assign(permix, { + contextKey(newKey: string | symbol) { + key = newKey + return permix + }, + }) +} + +/** Return type of {@link createPermix}. */ +export type NestPermix = ReturnType< + typeof createPermix +> diff --git a/permix/src/next/config.test.ts b/permix/src/next/config.test.ts new file mode 100644 index 00000000..ab2dfc58 --- /dev/null +++ b/permix/src/next/config.test.ts @@ -0,0 +1,60 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' + +import { afterEach, describe, expect, it } from 'vitest' + +import { createPermixPlugin, withPermix } from './config' + +const temporaryDirectories: string[] = [] + +describe(createPermixPlugin, () => { + afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })) + ) + }) + + it('generates artifacts before returning the Next config', async () => { + const cwd = await mkdtemp(path.join(tmpdir(), 'permix-next-config-')) + temporaryDirectories.push(cwd) + await writeFile( + path.join(cwd, 'permissions.ts'), + `import { permission } from 'permix' +permission('projects.read') +` + ) + const nextConfig = { + reactStrictMode: true, + } + const withPermix = createPermixPlugin({ + cwd, + watch: false, + }) + + await expect(withPermix(nextConfig)).resolves.toBe(nextConfig) + await expect( + readFile(path.join(cwd, '.permix/permissions.json'), 'utf-8') + ).resolves.toContain('"projects.read"') + }) + + it('supports the direct withPermix config convention', async () => { + const cwd = await mkdtemp(path.join(tmpdir(), 'permix-next-with-config-')) + temporaryDirectories.push(cwd) + await writeFile( + path.join(cwd, 'permissions.ts'), + `import { permission } from 'permix' +permission('projects.read') +` + ) + const nextConfig = { + poweredByHeader: false, + } + + await expect(withPermix(nextConfig, { cwd, watch: false })).resolves.toBe( + nextConfig + ) + }) +}) diff --git a/permix/src/next/config.ts b/permix/src/next/config.ts new file mode 100644 index 00000000..54646c57 --- /dev/null +++ b/permix/src/next/config.ts @@ -0,0 +1,77 @@ +import path from 'node:path' + +import type { + GeneratePermissionsOptions, + PermissionWatcher, +} from '../extractor' +import { generatePermissions, watchPermissions } from '../extractor' + +export interface PermixNextPluginOptions extends GeneratePermissionsOptions { + /** + * Watch source files after the initial extraction. + * + * Defaults to `true` during development and `false` otherwise. + */ + readonly watch?: boolean +} + +const activeWatchers = new Map>() + +function watcherKey(options: PermixNextPluginOptions): string { + return JSON.stringify({ + cwd: path.resolve(options.cwd ?? process.cwd()), + include: options.include ?? null, + exclude: options.exclude ?? null, + moduleOutput: options.moduleOutput ?? null, + catalogOutput: options.catalogOutput ?? null, + }) +} + +async function startWatcher(options: PermixNextPluginOptions): Promise { + const key = watcherKey(options) + const existing = activeWatchers.get(key) + if (existing !== undefined) { + await existing + return + } + + const started = watchPermissions(options, (event) => { + if (event.type === 'error') { + console.error(event.error) + } + }) + activeWatchers.set(key, started) + await started +} + +/** + * Enhances a Next.js config and generates permission artifacts before Next + * starts compiling the application. + */ +export async function withPermix>( + nextConfig?: Config, + options: PermixNextPluginOptions = {} +): Promise { + const shouldWatch = + options.watch ?? + (process.env.NODE_ENV === 'development' && process.env.CI === undefined) + + if (shouldWatch) { + await startWatcher(options) + } else { + await generatePermissions(options) + } + + return nextConfig ?? ({} as Config) +} + +/** + * Creates a reusable configured `withPermix` wrapper for config composition. + */ +export function createPermixPlugin(options: PermixNextPluginOptions = {}) { + return async function configuredWithPermix< + Config extends object = Record, + >(nextConfig?: Config): Promise { + return withPermix(nextConfig, options) + } +} diff --git a/permix/src/next/hydration.test.tsx b/permix/src/next/hydration.test.tsx index ddb0e13c..0de3645c 100644 --- a/permix/src/next/hydration.test.tsx +++ b/permix/src/next/hydration.test.tsx @@ -1,53 +1,47 @@ import { render } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { createPermix as createCorePermix } from '../core' -import { PermixHydrate, PermixProvider, usePermix } from '../react' +import { createPermix as createReactPermix } from '../react' import { createPermix as createNextPermix } from './permix' +import { resetRequestCache } from './request-cache-mock' import '@testing-library/jest-dom/vitest' -// See ./permix.test.ts — outside a Next.js request scope `cache()` does not -// memoize, so we mock it for the test environment. vi.mock('react', async () => { const actual = await vi.importActual('react') + const { createRequestScopedCache } = await import('./request-cache-mock') return { ...actual, - cache: any>(fn: T): T => { - const store = new Map>() - return ((...args: Parameters) => { - const key = JSON.stringify(args) - if (!store.has(key)) { - store.set(key, fn(...args)) - } - return store.get(key)! - }) as T - }, + cache: createRequestScopedCache, } }) describe('next → react hydration round-trip', () => { + afterEach(() => { + resetRequestCache() + }) + it('hydrates dehydrated server state on the client', async () => { - // Server: setup + dehydrate using the Next.js per-request helper. const permixServer = createNextPermix<{ post: ['create', 'read'] - }>() - - permixServer.setup({ + }>(() => ({ post: { create: true, read: false, }, - }) + })) - const state = permixServer.dehydrate() + const state = await permixServer.dehydrate() - // Client: separate singleton + hydrate via the react integration. const permixClient = createCorePermix<{ post: ['create', 'read'] }>() + const { PermixProvider, PermixHydrate, usePermix } = + createReactPermix(permixClient) + function PostStatus() { - const { check } = usePermix(permixClient) + const { check } = usePermix() return (

{String(check('post.create'))} @@ -57,7 +51,7 @@ describe('next → react hydration round-trip', () => { } const { getByTestId } = render( - + diff --git a/permix/src/next/permix.test.ts b/permix/src/next/permix.test.ts index 1b591908..7f6cd31b 100644 --- a/permix/src/next/permix.test.ts +++ b/permix/src/next/permix.test.ts @@ -1,93 +1,100 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { createPermix } from './permix' +import { resetRequestCache } from './request-cache-mock' -// React's `cache()` only memoizes within a Next.js request scope (backed by -// AsyncLocalStorage). Outside of one — including in vitest — it returns a -// fresh value on every call, which would defeat the per-request instance -// pattern. We replace it here with a simple module-level memoizer so that a -// single `createPermix()` call behaves as if it were running inside one -// Next.js request for the duration of the test. vi.mock('react', async () => { const actual = await vi.importActual('react') + const { createRequestScopedCache } = await import('./request-cache-mock') return { ...actual, - cache: any>(fn: T): T => { - const store = new Map>() - return ((...args: Parameters) => { - const key = JSON.stringify(args) - if (!store.has(key)) { - store.set(key, fn(...args)) + cache: createRequestScopedCache, + use: (usable: T | PromiseLike): T => { + if ( + usable !== null && + usable !== undefined && + typeof (usable as PromiseLike).then === 'function' + ) { + const thenable = usable as PromiseLike & { + status?: 'fulfilled' | 'rejected' | 'pending' + value?: T + reason?: unknown } - return store.get(key)! - }) as T + if (thenable.status === 'fulfilled') { + return thenable.value as T + } + if (thenable.status === 'rejected') { + throw thenable.reason + } + throw thenable + } + return usable as T }, } }) describe('next createPermix', () => { - it('sets up rules and checks permissions', () => { + afterEach(() => { + resetRequestCache() + }) + + // Unit tests below encode the mock `cache()` contract in + // `request-cache-mock.ts`, not React/Next ALS. Request isolation is + // proven in Playwright `permix/test/next/tests/compat.spec.ts`. + + it('initializes from a sync resolver and checks permissions', async () => { const permix = createPermix<{ post: ['create', 'read'] - }>() - - permix.setup({ + }>(() => ({ post: { create: true, read: false, }, - }) + })) - expect(permix.check('post.create')).toBe(true) - expect(permix.check('post.read')).toBe(false) + await expect(permix.check('post.create')).resolves.toBe(true) + await expect(permix.check('post.read')).resolves.toBe(false) }) - it('works with data resolved before setup', async () => { + it('initializes from an async resolver', async () => { const permix = createPermix<{ post: ['create'] - }>() - - // Mimic the documented pattern: await your own data, then call setup - // with plain rules. No async wrapper around setup needed. - const user = await Promise.resolve({ role: 'admin' as const }) - - permix.setup({ - post: { - create: user.role === 'admin', - }, + }>(async () => { + const user = await Promise.resolve({ role: 'admin' as const }) + return { + post: { + create: user.role === 'admin', + }, + } }) - expect(permix.check('post.create')).toBe(true) + await expect(permix.check('post.create')).resolves.toBe(true) }) - it('exposes the underlying core instance via get()', () => { + it('returns the initialized core instance from getPermix', async () => { const permix = createPermix<{ post: ['create'] - }>() - - permix.setup({ post: { create: true } }) + }>(() => ({ post: { create: true } })) - const core = permix.get() + const core = await permix.getPermix() expect(core.isReady()).toBe(true) expect(core.check('post.create')).toBe(true) }) - it('reads the current rules with getRules', () => { + it('reads the current rules with getRules', async () => { const permix = createPermix<{ post: ['create', 'read'] - }>() - - expect(permix.getRules()).toBeNull() - - permix.setup({ + }>(() => ({ post: { create: true, read: false, }, - }) + })) - expect(permix.getRules()).toStrictEqual({ + // Frozen rules use a null prototype, so toStrictEqual would fail on constructor. + // oxlint-disable-next-line vitest/prefer-strict-equal + await expect(permix.getRules()).resolves.toEqual({ post: { create: true, read: false, @@ -95,19 +102,17 @@ describe('next createPermix', () => { }) }) - it('dehydrates the request-scoped state', () => { + it('dehydrates the request-scoped state', async () => { const permix = createPermix<{ post: ['create', 'read'] - }>() - - permix.setup({ + }>(() => ({ post: { create: true, read: false, }, - }) + })) - expect(permix.dehydrate()).toStrictEqual({ + await expect(permix.dehydrate()).resolves.toStrictEqual({ post: { create: true, read: false, @@ -115,35 +120,86 @@ describe('next createPermix', () => { }) }) - it('reuses the same instance across calls in the same request scope', () => { + it('shares one initialized instance across concurrent callers', async () => { + let calls = 0 const permix = createPermix<{ post: ['create'] - }>() + }>(async () => { + calls++ + await Promise.resolve() + return { post: { create: true } } + }) - permix.setup({ post: { create: true } }) + const [first, second, allowed] = await Promise.all([ + permix.getPermix(), + permix.getPermix(), + permix.check('post.create'), + ]) - // Two get() calls in the same "request" must return the same instance, - // proving that setup() persists across subsequent calls. - expect(permix.get()).toBe(permix.get()) - expect(permix.check('post.create')).toBe(true) + expect(calls).toBe(1) + expect(first).toBe(second) + expect(allowed).toBe(true) + expect(first.check('post.create')).toBe(true) }) - it('isolates state between independent factories', () => { - const permixA = createPermix<{ post: ['create'] }>() - const permixB = createPermix<{ post: ['create'] }>() + it('isolates state between independent factories', async () => { + const permixA = createPermix<{ post: ['create'] }>(() => ({ + post: { create: true }, + })) + const permixB = createPermix<{ post: ['create'] }>(() => ({ + post: { create: false }, + })) + + await expect(permixA.check('post.create')).resolves.toBe(true) + await expect(permixB.check('post.create')).resolves.toBe(false) + const [coreA, coreB] = await Promise.all([ + permixA.getPermix(), + permixB.getPermix(), + ]) + expect(coreA).not.toBe(coreB) + }) + + it('isolates state across simulated requests of the same factory', async () => { + let requestRole: 'admin' | 'guest' = 'admin' + const permix = createPermix<{ post: ['create'] }>(() => ({ + post: { create: requestRole === 'admin' }, + })) - permixA.setup({ post: { create: true } }) - permixB.setup({ post: { create: false } }) + await expect(permix.check('post.create')).resolves.toBe(true) + const first = await permix.getPermix() - expect(permixA.check('post.create')).toBe(true) - expect(permixB.check('post.create')).toBe(false) - expect(permixA.get()).not.toBe(permixB.get()) + resetRequestCache() + requestRole = 'guest' + + await expect(permix.check('post.create')).resolves.toBe(false) + await expect(permix.getPermix()).resolves.not.toBe(first) }) - it('creates reusable templates', () => { + it('usePermix unwraps the same initialized instance', async () => { + const permix = createPermix<{ + post: ['create'] + }>(() => ({ post: { create: true } })) + + const instancePromise = permix.getPermix() + const instance = await instancePromise + Object.assign(instancePromise, { + status: 'fulfilled', + value: instance, + }) + + expect(permix.usePermix()).toBe(instance) + expect(instance.check('post.create')).toBe(true) + }) + + it('creates reusable templates without waiting on initialization', () => { const permix = createPermix<{ post: ['create', 'read'] - }>() + }>(() => ({ + post: { + create: false, + read: false, + }, + })) const adminTemplate = permix.template({ post: { @@ -163,7 +219,11 @@ describe('next createPermix', () => { it('supports parameterized templates', () => { const permix = createPermix<{ post: [{ name: 'edit'; type: { authorId: string } }] - }>() + }>(() => ({ + post: { + edit: () => false, + }, + })) const template = permix.template((userId: string) => ({ post: { diff --git a/permix/src/next/permix.ts b/permix/src/next/permix.ts index 742b3211..aebbd8d4 100644 --- a/permix/src/next/permix.ts +++ b/permix/src/next/permix.ts @@ -1,101 +1,105 @@ -import { cache } from 'react' +import { cache, use } from 'react' import type { Permix as PermixCore } from '../core' import { createPermix as createPermixCore, createTemplate } from '../core' +import type { CheckArgs } from '../core/check' import type { Definition } from '../core/definitions' -import type { PermixHooks, Rules, RulesPaths } from '../core/permix' +import type { Rules, RulesPaths } from '../core/permix' import type { DehydratedState } from '../core/rules' +export type ResolveRules = () => + | Rules + | Promise> + /** - * Create a per-request Permix instance for Next.js App Router. + * Create a per-request Permix helper for the Next.js App Router. + * + * Pass a sync or async rules resolver. React's `cache()` memoizes one Promise + * of a fully initialized core instance per request, so concurrent RSC callers + * share the same setup instead of racing layout mutation order. + * + * Use the async getters from async Server Components, Route Handlers, and + * Server Actions. Use `usePermix()` from non-async Server Components — it + * unwraps the same Promise with React `use()`, so `check()` stays synchronous + * at the call site while React may suspend only while rules resolve. * - * Backed by React's `cache()`, so all server components, route handlers, and - * server actions within the same request share one instance while concurrent - * requests stay fully isolated. + * Route Handlers and Server Actions do not share the RSC `cache()` identity. + * Prefer an explicit core `createPermix()` + `setup()` inside each invocation + * when those entry points must authorize independently of the render tree. * * @example * ```ts * // lib/permix.ts * import { createPermix } from 'permix/next' + * import { getSession } from '@/lib/auth' * * export const permix = createPermix<{ * post: ['create', 'read', 'update', 'delete'] - * }>() - * ``` - * - * ```tsx - * // app/layout.tsx (server component) - * import { permix } from '@/lib/permix' - * import { getSession } from '@/lib/auth' - * - * export default async function RootLayout({ children }) { + * }>(async () => { * const session = await getSession() - * - * permix.setup({ + * return { * post: { * create: !!session, * read: true, * update: session?.role === 'admin', * delete: session?.role === 'admin', * }, - * }) + * } + * }) + * ``` + * + * ```tsx + * // async Server Component + * const canCreate = await permix.check('post.create') * - * return {children} - * } + * // non-async Server Component + * const instance = permix.usePermix() + * const canRead = instance.check('post.read') * ``` * * @link https://permix.letstri.dev/docs/integrations/next */ -export function createPermix() { - // Per-request isolation: concurrent requests each get their own instance; - // multiple callers within one request share the same one. - const getPermix = cache((): PermixCore => createPermixCore()) +export function createPermix( + resolveRules: ResolveRules +) { + const getInitializedPermix = cache(async (): Promise> => + createPermixCore().setup(await Promise.resolve(resolveRules())) + ) - function setup(rules: Rules): void { - getPermix().setup(rules) + function getPermix(): Promise> { + return getInitializedPermix() } - const check: PermixCore['check'] = (...args) => getPermix().check(...args) + function usePermix(): PermixCore { + return use(getInitializedPermix()) + } - function dehydrate(): DehydratedState { - return getPermix().dehydrate() + async function check(...args: CheckArgs): Promise { + const permix = await getInitializedPermix() + return permix.check(...args) } - function get(): PermixCore { - return getPermix() + async function getRules(): Promise | null> { + const permix = await getInitializedPermix() + return permix.getRules() } - function getRules(): Rules | null { - return getPermix().getRules() + async function dehydrate(): Promise> { + const permix = await getInitializedPermix() + return permix.dehydrate() } function template(rules: Rules | ((param: T) => Rules)) { return createTemplate(rules) } - function hook>( - name: K, - fn: PermixHooks[K] - ) { - return getPermix().hook(name, fn) - } - - function hookOnce>( - name: K, - fn: PermixHooks[K] - ) { - getPermix().hookOnce(name, fn) - } - return { - setup, + getPermix, + usePermix, check, - dehydrate, - get, getRules, + dehydrate, template, - hook, - hookOnce, $inferDefinition: undefined as unknown as D, $inferPath: undefined as unknown as RulesPaths, } diff --git a/permix/src/next/request-cache-mock.ts b/permix/src/next/request-cache-mock.ts new file mode 100644 index 00000000..b06f4045 --- /dev/null +++ b/permix/src/next/request-cache-mock.ts @@ -0,0 +1,34 @@ +/** + * Request-scoped stand-in for React's `cache()` in vitest. + * + * Real `cache()` memoizes within a Next.js request (AsyncLocalStorage). In + * vitest there is no request scope, so the default implementation does not + * share work across callers. This helper memoizes like one request, and + * {@link resetRequestCache} starts a new generation so tests can simulate + * the next request without pretending the cache is process-global forever. + */ +let generation = 0 + +export function resetRequestCache(): void { + generation++ +} + +export function createRequestScopedCache any>( + fn: T +): T { + const stores = new Map>>() + + return ((...args: Parameters) => { + let store = stores.get(generation) + if (!store) { + store = new Map() + stores.set(generation, store) + } + + const key = JSON.stringify(args) + if (!store.has(key)) { + store.set(key, fn(...args) as ReturnType) + } + return store.get(key)! + }) as T +} diff --git a/permix/src/node/permix.test.ts b/permix/src/node/permix.test.ts index 18165c19..ce0b6aa6 100644 --- a/permix/src/node/permix.test.ts +++ b/permix/src/node/permix.test.ts @@ -325,7 +325,7 @@ describe('checkMiddleware without setupMiddleware', () => { await permix.checkMiddleware('post.create')(req, res, next) expect(next).toHaveBeenCalledOnce() - expect(next.mock.calls[0][0]).toBeInstanceOf(PermixNotFoundError) + expect(next.mock.calls[0]?.[0]).toBeInstanceOf(PermixNotFoundError) }) }) @@ -354,6 +354,25 @@ describe('onForbidden receives next', () => { }) }) +describe('async middleware errors', () => { + it('should forward a rejecting setup callback to next(err)', async () => { + const permix = createPermix() + + const req = createMockRequest() + const res = createMockResponse() + const next = createMockNext() + + await permix.setupMiddleware(async () => { + throw new Error('setup failed') + })(req, res, next) + + expect(next).toHaveBeenCalledOnce() + const setupError = next.mock.calls[0]?.[0] + expect(setupError).toBeInstanceOf(Error) + expect(setupError).toHaveProperty('message', 'setup failed') + }) +}) + describe('key exposure', () => { it('should expose the key on the factory return', () => { const permix = diff --git a/permix/src/node/permix.ts b/permix/src/node/permix.ts index 135c2020..0bfb45d8 100644 --- a/permix/src/node/permix.ts +++ b/permix/src/node/permix.ts @@ -1,16 +1,15 @@ import type { IncomingMessage, ServerResponse } from 'node:http' import type { Permix as PermixCore } from '../core' -import { - createCheckContext, - createHooks, - createPermix as createPermixCore, - createTemplate, - PermixNotFoundError, -} from '../core' +import { PermixNotFoundError, withDenialReasons } from '../core' import type { CheckArgs, CheckContext } from '../core/check' import type { Definition } from '../core/definitions' -import type { PermixHooks, Rules, RulesPaths } from '../core/permix' +import type { Rules } from '../core/permix' +import { + createRequestKernel, + propertyBagStore, + withContextKey, +} from '../server/kernel' import type { MaybePromise } from '../utils' type NextFunction = (err?: unknown) => void @@ -37,102 +36,6 @@ export interface PermixOptions { ) => MaybePromise } -function buildPermix( - resolveKey: () => string | symbol, - options: PermixOptions = {} -) { - const onForbidden = - options.onForbidden ?? - (({ res }) => { - res.statusCode = 403 - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify({ error: 'Forbidden' })) - }) - - const hooks = createHooks>() - - function get(req: IncomingMessage): PermixCore | null { - const instance = (req as any)[resolveKey()] as PermixCore | undefined - return instance ?? null - } - - function getOrThrow(req: IncomingMessage): PermixCore { - const instance = get(req) - if (!instance) { - throw new PermixNotFoundError(resolveKey()) - } - return instance - } - - function setupMiddleware( - callbackOrRules: - | ((context: MiddlewareContext) => MaybePromise>) - | Rules - ): Handler { - return async (req, res, next) => { - const rules = - typeof callbackOrRules === 'function' - ? await callbackOrRules({ req, res, next }) - : callbackOrRules - const instance = createPermixCore(rules) - instance.hook('check', (context) => { - hooks.callHook('check', context) - }) - ;(req as any)[resolveKey()] = instance - next() - } - } - - const checkMiddleware: (...args: CheckArgs) => Handler = - (...args) => - async (req, res, next) => { - const permix = get(req) - - if (!permix) { - next(new PermixNotFoundError(resolveKey())) - return - } - - const allowed = permix.check(...args) - - if (!allowed) { - await onForbidden({ - req, - res, - next, - ...createCheckContext(...args), - }) - return - } - - next() - } - - function getRules(req: IncomingMessage): Rules | null { - return get(req)?.getRules() ?? null - } - - function template(rules: Rules | ((param: T) => Rules)) { - return createTemplate(rules) - } - - return { - setupMiddleware, - checkMiddleware, - template, - get, - getOrThrow, - getRules, - hook: hooks.hook, - hookOnce: hooks.hookOnce, - get key() { - return resolveKey() - }, - $inferDefinition: undefined as unknown as D, - $inferPath: undefined as unknown as RulesPaths, - } -} - /** * Create a middleware factory that wires Permix into raw Node.js HTTP servers. * @@ -164,14 +67,95 @@ function buildPermix( export function createPermix( options: PermixOptions = {} ) { - let key: string | symbol = Symbol('permix') - const permix = buildPermix(() => key, options) - - return Object.assign(permix, { - contextKey(newKey: string | symbol) { - key = newKey - return permix - }, + const onForbidden = + options.onForbidden ?? + (({ res }) => { + res.statusCode = 403 + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ error: 'Forbidden' })) + }) + + return withContextKey((resolveKey) => { + const kernel = createRequestKernel( + resolveKey, + propertyBagStore(resolveKey) + ) + + function get(req: IncomingMessage): PermixCore | null { + return kernel.get(req) + } + + function getOrThrow(req: IncomingMessage): PermixCore { + return kernel.getOrThrow(req) + } + + function getRules(req: IncomingMessage): Rules | null { + return kernel.getRules(req) + } + + function setupMiddleware( + callbackOrRules: + | ((context: MiddlewareContext) => MaybePromise>) + | Rules + ): Handler { + return async (req, res, next) => { + try { + const rules = + typeof callbackOrRules === 'function' + ? await callbackOrRules({ req, res, next }) + : callbackOrRules + kernel.attach(req, rules) + next() + } catch (error) { + next(error) + } + } + } + + const checkMiddleware: (...args: CheckArgs) => Handler = + (...args) => + async (req, res, next) => { + try { + const permix = get(req) + + if (!permix) { + next(new PermixNotFoundError(resolveKey())) + return + } + + const allowed = permix.check(...args) + + if (!allowed) { + await onForbidden({ + req, + res, + next, + ...withDenialReasons(permix, args), + }) + return + } + + next() + } catch (error) { + next(error) + } + } + + return { + setupMiddleware, + checkMiddleware, + template: kernel.template, + get, + getOrThrow, + getRules, + hook: kernel.hook, + hookOnce: kernel.hookOnce, + get key() { + return kernel.key + }, + $inferDefinition: kernel.$inferDefinition, + $inferPath: kernel.$inferPath, + } }) } diff --git a/permix/src/nuxt/index.ts b/permix/src/nuxt/index.ts new file mode 100644 index 00000000..60dafabb --- /dev/null +++ b/permix/src/nuxt/index.ts @@ -0,0 +1 @@ +export * from './permix' diff --git a/permix/src/nuxt/permix.test.ts b/permix/src/nuxt/permix.test.ts new file mode 100644 index 00000000..bb399b35 --- /dev/null +++ b/permix/src/nuxt/permix.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it, vi } from 'vitest' + +import { createPermix } from './permix' +import type { NuxtEvent } from './permix' + +let currentEvent: NuxtEvent | undefined + +vi.mock('h3', () => ({ + getRequestEvent: () => currentEvent, +})) + +async function withEvent( + fn: (event: NuxtEvent) => T | Promise +): Promise { + currentEvent = { context: {} } + try { + return await fn(currentEvent) + } finally { + currentEvent = undefined + } +} + +describe('nuxt createPermix', () => { + it('throws when no request event is available', () => { + const permix = createPermix<{ + post: ['create'] + }>() + + expect(() => { + permix.setup({ + post: { + create: true, + }, + }) + }).toThrow(/No request event found/) + }) + + it('sets up rules and checks permissions', async () => { + await withEvent(() => { + const permix = createPermix<{ + post: ['create', 'read'] + }>() + + permix.setup({ + post: { + create: true, + read: false, + }, + }) + + expect(permix.check('post.create')).toBe(true) + expect(permix.check('post.read')).toBe(false) + }) + }) + + it('works with data resolved before setup', async () => { + await withEvent(async () => { + const permix = createPermix<{ + post: ['create'] + }>() + + const user = await Promise.resolve({ role: 'admin' as const }) + + permix.setup({ + post: { + create: user.role === 'admin', + }, + }) + + expect(permix.check('post.create')).toBe(true) + }) + }) + + it('exposes the underlying core instance via get()', async () => { + await withEvent((event) => { + const permix = createPermix<{ + post: ['create'] + }>() + + permix.setup({ post: { create: true } }, event) + + const core = permix.get(event) + + expect(core.isReady()).toBe(true) + expect(core.check('post.create')).toBe(true) + }) + }) + + it('reads the current rules with getRules', async () => { + await withEvent(() => { + const permix = createPermix<{ + post: ['create', 'read'] + }>() + + expect(permix.getRules()).toBeNull() + + permix.setup({ + post: { + create: true, + read: false, + }, + }) + + // Frozen rules use a null prototype, so toStrictEqual would fail on constructor. + // oxlint-disable-next-line vitest/prefer-strict-equal + expect(permix.getRules()).toEqual({ + post: { + create: true, + read: false, + }, + }) + }) + }) + + it('dehydrates the request-scoped state', async () => { + await withEvent(() => { + const permix = createPermix<{ + post: ['create', 'read'] + }>() + + permix.setup({ + post: { + create: true, + read: false, + }, + }) + + expect(permix.dehydrate()).toStrictEqual({ + post: { + create: true, + read: false, + }, + }) + }) + }) + + it('reuses the same instance across calls in the same request scope', async () => { + await withEvent(() => { + const permix = createPermix<{ + post: ['create'] + }>() + + permix.setup({ post: { create: true } }) + + expect(permix.get()).toBe(permix.get()) + expect(permix.check('post.create')).toBe(true) + }) + }) + + it('isolates state between independent factories', async () => { + await withEvent(() => { + const permixA = createPermix<{ post: ['create'] }>() + const permixB = createPermix<{ post: ['create'] }>() + + permixA.setup({ post: { create: true } }) + permixB.setup({ post: { create: false } }) + + expect(permixA.check('post.create')).toBe(true) + expect(permixB.check('post.create')).toBe(false) + expect(permixA.get()).not.toBe(permixB.get()) + }) + }) + + it('isolates state between concurrent events', () => { + const permix = createPermix<{ post: ['create'] }>() + const eventA: NuxtEvent = { context: {} } + const eventB: NuxtEvent = { context: {} } + + permix.setup({ post: { create: true } }, eventA) + permix.setup({ post: { create: false } }, eventB) + + expect(permix.get(eventA).check('post.create')).toBe(true) + expect(permix.get(eventB).check('post.create')).toBe(false) + expect(permix.get(eventA)).not.toBe(permix.get(eventB)) + }) + + it('creates reusable templates', () => { + const permix = createPermix<{ + post: ['create', 'read'] + }>() + + const adminTemplate = permix.template({ + post: { + create: true, + read: true, + }, + }) + + expect(adminTemplate()).toStrictEqual({ + post: { + create: true, + read: true, + }, + }) + }) + + it('supports parameterized templates', () => { + const permix = createPermix<{ + post: [{ name: 'edit'; type: { authorId: string } }] + }>() + + const template = permix.template((userId: string) => ({ + post: { + edit: (post: { authorId: string } | undefined) => + post?.authorId === userId, + }, + })) + + const rules = template('user-1') + const editFn = rules.post.edit as ( + post: { authorId: string } | undefined + ) => boolean + + expect(editFn({ authorId: 'user-1' })).toBe(true) + expect(editFn({ authorId: 'user-2' })).toBe(false) + }) +}) diff --git a/permix/src/nuxt/permix.ts b/permix/src/nuxt/permix.ts new file mode 100644 index 00000000..b8ced406 --- /dev/null +++ b/permix/src/nuxt/permix.ts @@ -0,0 +1,165 @@ +import * as h3 from 'h3' + +import type { Permix as PermixCore } from '../core' +import { createPermix as createPermixCore, createTemplate } from '../core' +import type { Definition } from '../core/definitions' +import type { PermixHooks, Rules, RulesPaths } from '../core/permix' +import type { DehydratedState } from '../core/rules' + +/** + * Minimal Nitro/h3 event shape. Compatible with `H3Event` from `h3` and + * Nuxt's `useRequestEvent()` return value. + */ +export interface NuxtEvent { + context: object +} + +interface H3RequestEventApi { + getRequestEvent?: () => NuxtEvent | undefined + useEvent?: () => NuxtEvent +} + +function readCurrentEvent(): NuxtEvent | undefined { + // h3 1.x has no ALS helper; Nitro/h3 v2 may expose getRequestEvent or useEvent. + const runtime = h3 as H3RequestEventApi + if (typeof runtime.getRequestEvent === 'function') { + return runtime.getRequestEvent() + } + if (typeof runtime.useEvent === 'function') { + try { + return runtime.useEvent() + } catch { + return undefined + } + } + return undefined +} + +function resolveEvent( + event: NuxtEvent | undefined, + key: string | symbol +): NuxtEvent { + if (event) { + return event + } + const current = readCurrentEvent() + if (current) { + return current + } + throw new Error( + `[Permix]: No request event found for key ${String(key)}. Call setup() inside a Nuxt/Nitro request, or pass the event.` + ) +} + +function getOrCreate( + event: NuxtEvent, + key: string | symbol, + create: () => PermixCore +): PermixCore { + const context = event.context as Record + const existing = context[key] as PermixCore | undefined + if (existing) { + return existing + } + const instance = create() + context[key] = instance + return instance +} + +/** + * Create a per-request Permix instance for Nuxt / Nitro. + * + * The instance is stored on the current request's `event.context`, so server + * routes, server middleware, and Vue server components in the same request + * share one instance while concurrent requests stay isolated. + * + * @example + * ```ts + * // lib/permix.ts + * import { createPermix } from 'permix/nuxt' + * + * export const permix = createPermix<{ + * post: ['create', 'read', 'update', 'delete'] + * }>() + * ``` + * + * ```ts + * // server/middleware/permix.ts + * import { permix } from '~/lib/permix' + * + * export default defineEventHandler((event) => { + * permix.setup({ + * post: { create: true, read: true, update: false, delete: false }, + * }, event) + * }) + * ``` + * + * @link https://permix.letstri.dev/docs/integrations/nuxt + */ +export function createPermix() { + const key: symbol = Symbol('permix') + + function getPermix(event?: NuxtEvent): PermixCore { + const resolved = resolveEvent(event, key) + return getOrCreate(resolved, key, () => createPermixCore()) + } + + function setup(rules: Rules, event?: NuxtEvent): PermixCore { + const resolved = resolveEvent(event, key) + const instance = createPermixCore().setup(rules) + const context = resolved.context as Record + context[key] = instance + return instance + } + + const check: PermixCore['check'] = (...args) => getPermix().check(...args) + + function dehydrate(event?: NuxtEvent): DehydratedState { + return getPermix(event).dehydrate() + } + + function get(event?: NuxtEvent): PermixCore { + return getPermix(event) + } + + function getRules(event?: NuxtEvent): Rules | null { + return getPermix(event).getRules() + } + + function template(rules: Rules | ((param: T) => Rules)) { + return createTemplate(rules) + } + + function hook>( + name: K, + fn: PermixHooks[K], + event?: NuxtEvent + ) { + return getPermix(event).hook(name, fn) + } + + function hookOnce>( + name: K, + fn: PermixHooks[K], + event?: NuxtEvent + ) { + getPermix(event).hookOnce(name, fn) + } + + return { + setup, + check, + dehydrate, + get, + getRules, + template, + hook, + hookOnce, + $inferDefinition: undefined as unknown as D, + $inferPath: undefined as unknown as RulesPaths, + } +} + +export type NuxtPermix = ReturnType< + typeof createPermix +> diff --git a/permix/src/orpc/permix.ts b/permix/src/orpc/permix.ts index bea239c5..52946527 100644 --- a/permix/src/orpc/permix.ts +++ b/permix/src/orpc/permix.ts @@ -2,11 +2,11 @@ import { ORPCError, os } from '@orpc/server' import type { Permix as PermixCore } from '../core' import { - createCheckContext, createHooks, createPermix as createPermixCore, createTemplate, PermixNotFoundError, + withDenialReasons, } from '../core' import type { CheckArgs, CheckContext } from '../core/check' import type { Definition } from '../core/definitions' @@ -38,7 +38,7 @@ function buildPermix( const plugin = os.$context<{ [P in Key]: PermixCore }>() function setupContext(rules: Rules): { [P in Key]: PermixCore } { - const instance = createPermixCore(rules) + const instance = createPermixCore().setup(rules) instance.hook('check', (context) => { hooks.callHook('check', context) }) @@ -58,7 +58,7 @@ function buildPermix( return await opts.next() } - return forbiddenHandler({ ...opts, ...createCheckContext(...args) }) + return forbiddenHandler({ ...opts, ...withDenialReasons(instance, args) }) }) } diff --git a/permix/src/react-router/index.ts b/permix/src/react-router/index.ts new file mode 100644 index 00000000..60dafabb --- /dev/null +++ b/permix/src/react-router/index.ts @@ -0,0 +1 @@ +export * from './permix' diff --git a/permix/src/react-router/permix.test.ts b/permix/src/react-router/permix.test.ts new file mode 100644 index 00000000..03c4eaed --- /dev/null +++ b/permix/src/react-router/permix.test.ts @@ -0,0 +1,331 @@ +import { describe, expect, it, vi } from 'vitest' + +import type { ValidateDefinition } from '../core' +import { PermixNotFoundError } from '../core' +import { createPermix } from './permix' +import type { ReactRouterContext } from './permix' + +interface Post { + id: string + authorId: string +} + +type PermissionsDefinition = ValidateDefinition<{ + post: ['create', 'read', 'update'] + user: ['delete'] +}> + +type PostWithData = ValidateDefinition<{ + post: [{ name: 'create'; type: Post }] +}> + +function createMockContext(): ReactRouterContext { + const store = new Map() + return { + get: (key) => store.get(key), + set: (key, value) => { + store.set(key, value) + }, + } +} + +function createMockNext(response = new Response('ok')) { + return vi.fn(async () => response) +} + +describe(createPermix, () => { + const permix = createPermix() + + it('should throw ts error', () => { + // @ts-expect-error path does not exist + permix.checkMiddleware('post.delete') + }) + + it('should allow access when permission is granted', async () => { + const context = createMockContext() + const next = createMockNext() + const request = new Request('https://example.com') + + await permix.setupMiddleware({ + post: { create: true, read: false, update: false }, + user: { delete: false }, + })({ request, context }, next) + + const result = await permix.checkMiddleware('post.create')( + { request, context }, + next + ) + + expect(result?.status).toBe(200) + expect(next).toHaveBeenCalledTimes(2) + }) + + it('should deny access when permission is not granted', async () => { + const context = createMockContext() + const next = createMockNext() + const request = new Request('https://example.com') + + await permix.setupMiddleware({ + post: { create: false, read: false, update: false }, + user: { delete: false }, + })({ request, context }, next) + + const result = await permix.checkMiddleware('post.create')( + { request, context }, + next + ) + + expect(result?.status).toBe(403) + await expect(result?.text()).resolves.toBe( + JSON.stringify({ error: 'Forbidden' }) + ) + expect(next).toHaveBeenCalledOnce() + }) + + it('should work with custom error handler', async () => { + const permix = createPermix({ + onForbidden: () => + new Response(JSON.stringify({ error: 'Custom error' }), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }), + }) + + const context = createMockContext() + const next = createMockNext() + const request = new Request('https://example.com') + + await permix.setupMiddleware({ + post: { create: false, read: false, update: false }, + user: { delete: false }, + })({ request, context }, next) + + const result = await permix.checkMiddleware('post.create')( + { request, context }, + next + ) + + expect(result?.status).toBe(403) + await expect(result?.text()).resolves.toBe( + JSON.stringify({ error: 'Custom error' }) + ) + }) + + it('should pass data through to a rule callback', async () => { + const permix = createPermix() + const context = createMockContext() + const next = createMockNext() + const request = new Request('https://example.com') + + await permix.setupMiddleware({ + post: { + create: (post) => post?.authorId === '1', + }, + })({ request, context }, next) + + const result = await permix.checkMiddleware('post.create', { + id: 'a', + authorId: '1', + })({ request, context }, next) + + expect(result?.status).toBe(200) + }) + + it('should work with checker callback form', async () => { + const permix = createPermix() + const context = createMockContext() + const next = createMockNext() + const request = new Request('https://example.com') + + await permix.setupMiddleware({ + post: { create: true, read: true, update: false }, + user: { delete: true }, + })({ request, context }, next) + + const result = await permix.checkMiddleware( + (c) => c('post.create') && c('user.delete') + )({ request, context }, next) + + expect(result?.status).toBe(200) + }) + + it('should work with an async setup callback that receives the request', async () => { + const permix = createPermix() + const context = createMockContext() + const next = createMockNext() + const request = new Request('https://example.com/?admin=1') + + await permix.setupMiddleware(async ({ request: req }) => ({ + post: { + create: new URL(req.url).searchParams.get('admin') === '1', + read: true, + update: false, + }, + user: { delete: false }, + }))({ request, context }, next) + + expect(permix.getOrThrow(context).check('post.create')).toBe(true) + }) + + it('should dehydrate permissions', async () => { + const permix = createPermix() + const context = createMockContext() + const next = createMockNext() + const request = new Request('https://example.com') + + await permix.setupMiddleware({ + post: { create: true, read: false, update: true }, + user: { delete: false }, + })({ request, context }, next) + + expect(permix.dehydrate(context)).toStrictEqual({ + post: { create: true, read: false, update: true }, + user: { delete: false }, + }) + }) + + it('should isolate instances between requests', async () => { + const permix = createPermix() + const next = createMockNext() + + const admin = createMockContext() + await permix.setupMiddleware({ + post: { create: true, read: true, update: true }, + user: { delete: true }, + })( + { request: new Request('https://example.com/admin'), context: admin }, + next + ) + + const guest = createMockContext() + await permix.setupMiddleware({ + post: { create: false, read: true, update: false }, + user: { delete: false }, + })({ request: new Request('https://example.com'), context: guest }, next) + + expect(permix.getOrThrow(admin).check('post.create')).toBe(true) + expect(permix.getOrThrow(guest).check('post.create')).toBe(false) + }) + + it('should isolate independent factories on the same request context', async () => { + const first = createPermix() + const second = createPermix() + const context = createMockContext() + const next = createMockNext() + const request = new Request('https://example.com') + + await first.setupMiddleware({ + post: { create: true, read: true, update: true }, + user: { delete: true }, + })({ request, context }, next) + await second.setupMiddleware({ + post: { create: false, read: false, update: false }, + user: { delete: false }, + })({ request, context }, next) + + expect(first.getOrThrow(context).check('post.create')).toBe(true) + expect(second.getOrThrow(context).check('post.create')).toBe(false) + }) + + it('should work with template', async () => { + const permix = createPermix() + const template = permix.template({ + post: { create: true, read: true, update: true }, + user: { delete: true }, + }) + + const context = createMockContext() + const next = createMockNext() + const request = new Request('https://example.com') + + await permix.setupMiddleware(() => template())({ request, context }, next) + + const result = await permix.checkMiddleware('post.create')( + { request, context }, + next + ) + + expect(result?.status).toBe(200) + }) +}) + +describe('get / getOrThrow', () => { + const permix = createPermix() + + it('should return null when setupMiddleware has not run', () => { + expect(permix.get(createMockContext())).toBeNull() + }) + + it('should return the instance when setupMiddleware has run', async () => { + const context = createMockContext() + const next = createMockNext() + + await permix.setupMiddleware({ + post: { create: true, read: true, update: true }, + user: { delete: true }, + })({ request: new Request('https://example.com'), context }, next) + + expect(permix.getOrThrow(context).check).toBeTypeOf('function') + }) + + it('getOrThrow should throw PermixNotFoundError when missing', () => { + expect(() => permix.getOrThrow(createMockContext())).toThrow( + PermixNotFoundError + ) + }) +}) + +describe('checkMiddleware without setupMiddleware', () => { + it('should throw PermixNotFoundError', async () => { + const permix = createPermix() + const next = createMockNext() + + await expect( + permix.checkMiddleware('post.create')( + { + request: new Request('https://example.com'), + context: createMockContext(), + }, + next + ) + ).rejects.toBeInstanceOf(PermixNotFoundError) + expect(next).not.toHaveBeenCalled() + }) +}) + +describe('React Router-style middleware composition', () => { + it('should compose setup and check middleware', async () => { + const permix = createPermix() + const context = createMockContext() + const request = new Request('https://example.com') + + const middleware = [ + permix.setupMiddleware({ + post: { create: true, read: false, update: false }, + user: { delete: false }, + }), + permix.checkMiddleware('post.create'), + ] + + let index = 0 + const dispatch = async (): Promise => { + const handler = middleware[index++] + if (handler) { + return await handler({ request, context }, dispatch) + } + return Response.json({ ok: true }) + } + + const res = await dispatch() + expect(res.status).toBe(200) + await expect(res.json()).resolves.toStrictEqual({ ok: true }) + }) +}) + +describe('context key', () => { + it('should expose a unique context key per factory', () => { + const first = createPermix() + const second = createPermix() + expect(first.context).not.toBe(second.context) + }) +}) diff --git a/permix/src/react-router/permix.ts b/permix/src/react-router/permix.ts new file mode 100644 index 00000000..49b74e95 --- /dev/null +++ b/permix/src/react-router/permix.ts @@ -0,0 +1,219 @@ +import type { Permix as PermixCore } from '../core' +import { + createHooks, + createPermix as createPermixCore, + createTemplate, + PermixNotFoundError, + withDenialReasons, +} from '../core' +import type { CheckArgs, CheckContext } from '../core/check' +import type { Definition } from '../core/definitions' +import type { PermixHooks, Rules, RulesPaths } from '../core/permix' +import type { DehydratedState } from '../core/rules' +import type { MaybePromise } from '../utils' + +/** + * Opaque key used with React Router's `context.set` / `context.get`. + * Compatible with `RouterContext` from `react-router`. + */ +export interface ReactRouterContextKey { + readonly __permix?: T +} + +/** + * Structural React Router middleware context. Compatible with + * `RouterContextProvider` from `react-router`. + */ +export interface ReactRouterContext { + get: (key: ReactRouterContextKey) => unknown + set: (key: ReactRouterContextKey, value: unknown) => void +} + +export interface MiddlewareArgs { + request: Request + context: ReactRouterContext + params?: Record +} + +export type MiddlewareNext = () => MaybePromise + +/** + * React Router middleware: `(args, next) => Response`. Compatible with + * `MiddlewareFunction` from `react-router` 7.9+. + */ +export type ReactRouterMiddleware = ( + args: MiddlewareArgs, + next: MiddlewareNext +) => MaybePromise + +export interface SetupContext { + request: Request + params?: Record +} + +export interface MiddlewareContext { + request: Request + context: ReactRouterContext + next: MiddlewareNext +} + +export interface PermixOptions { + /** + * Called when a `checkMiddleware` denies the request. Defaults to a 403 JSON + * response of `{ error: 'Forbidden' }`. + */ + onForbidden?: ( + params: CheckContext & MiddlewareContext + ) => MaybePromise +} + +function buildPermix( + resolveKey: () => ReactRouterContextKey>, + options: PermixOptions = {} +) { + const onForbidden = + options.onForbidden ?? + (() => + new Response(JSON.stringify({ error: 'Forbidden' }), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + })) + + const hooks = createHooks>() + + function get( + context: ReactRouterContext | null | undefined + ): PermixCore | null { + const instance = context?.get(resolveKey()) as PermixCore | undefined + return instance ?? null + } + + function getOrThrow( + context: ReactRouterContext | null | undefined + ): PermixCore { + const instance = get(context) + if (!instance) { + throw new PermixNotFoundError() + } + return instance + } + + function setupMiddleware( + callbackOrRules: + | ((setup: SetupContext) => MaybePromise>) + | Rules + ): ReactRouterMiddleware { + return async ({ request, context, params }, next) => { + const rules = + typeof callbackOrRules === 'function' + ? await callbackOrRules({ + request, + ...(params === undefined ? {} : { params }), + }) + : callbackOrRules + const instance = createPermixCore().setup(rules) + instance.hook('check', (checkContext) => { + hooks.callHook('check', checkContext) + }) + context.set(resolveKey(), instance) + return await next() + } + } + + const checkMiddleware: (...args: CheckArgs) => ReactRouterMiddleware = + (...args) => + async ({ request, context }, next) => { + const permix = get(context) + + if (!permix) { + throw new PermixNotFoundError() + } + + const allowed = permix.check(...args) + + if (!allowed) { + return await onForbidden({ + request, + context, + next, + ...withDenialReasons(permix, args), + }) + } + + return await next() + } + + function dehydrate( + context: ReactRouterContext | null | undefined + ): DehydratedState { + return getOrThrow(context).dehydrate() + } + + function getRules( + context: ReactRouterContext | null | undefined + ): Rules | null { + return get(context)?.getRules() ?? null + } + + function template(rules: Rules | ((param: T) => Rules)) { + return createTemplate(rules) + } + + return { + setupMiddleware, + checkMiddleware, + get, + getOrThrow, + dehydrate, + getRules, + template, + hook: hooks.hook, + hookOnce: hooks.hookOnce, + get context() { + return resolveKey() + }, + $inferDefinition: undefined as unknown as D, + $inferPath: undefined as unknown as RulesPaths, + } +} + +/** + * Create a per-request Permix helper for React Router 7 (including Remix). + * + * Uses React Router middleware context (`context.set` / `context.get`) so + * loaders, actions, and middleware share one instance per request. Hydrate + * the client with `permix/react`. + * + * @example + * ```ts + * // app/lib/permix.ts + * import { createPermix } from 'permix/react-router' + * + * export const permix = createPermix<{ + * post: ['create', 'read', 'update', 'delete'] + * }>() + * ``` + * + * ```ts + * // app/root.tsx + * import { permix } from './lib/permix' + * + * export const middleware = [ + * permix.setupMiddleware(({ request }) => ({ + * post: { create: true, read: true, update: false, delete: false }, + * })), + * ] + * ``` + * + * @link https://permix.letstri.dev/docs/integrations/react-router + */ +export function createPermix( + options: PermixOptions = {} +) { + const key: ReactRouterContextKey> = {} + return buildPermix(() => key, options) +} + +export type ReactRouterPermix = ReturnType< + typeof createPermix +> diff --git a/permix/src/react/components.test.tsx b/permix/src/react/components.test.tsx index 28a0050c..1649521e 100644 --- a/permix/src/react/components.test.tsx +++ b/permix/src/react/components.test.tsx @@ -1,18 +1,19 @@ -import { render, waitFor } from '@testing-library/react' -import { describe, expect, it } from 'vitest' +import { act, render, waitFor } from '@testing-library/react' +import * as React from 'react' +// @ts-expect-error react-dom/server has no types under tsconfig.react.json `types: []` +import { renderToString } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import '@testing-library/jest-dom/vitest' import { createPermix, PermixRuleNotDefinedError } from '../core' import { createComponents, PermixHydrate, PermixProvider } from './components' import { usePermix } from './hooks' -import '@testing-library/jest-dom/vitest' describe('components', () => { it('should check hydration', async () => { const permixServer = createPermix<{ post: ['create', 'read'] - }>() - - permixServer.setup({ + }>().setup({ post: { create: true, read: false, @@ -41,12 +42,148 @@ describe('components', () => { expect(container.firstChild).toHaveTextContent('true') }) + it('uses dehydrated rules on the first render without mutating the instance', () => { + const permixServer = createPermix<{ + post: ['create', 'read'] + }>().setup({ + post: { + create: true, + read: false, + }, + }) + + const dehydrated = permixServer.dehydrate() + const permixClient = createPermix<{ + post: ['create', 'read'] + }>() + + function TestComponent() { + const { check, isReady } = usePermix(permixClient) + return
{`${check('post.create')}:${isReady}`}
+ } + + const html = renderToString( + + + + + + ) + + expect(html).toContain('true:false') + expect(permixClient.getRules()).toBeNull() + expect(permixClient.isReady()).toBe(false) + }) + + it('keeps isReady false after hydration until client setup', async () => { + const permixServer = createPermix<{ + post: ['create', 'read'] + }>().setup({ + post: { + create: true, + read: false, + }, + }) + + const dehydrated = permixServer.dehydrate() + const permixClient = createPermix<{ + post: ['create', 'read'] + }>() + + function TestComponent() { + const { check, isReady } = usePermix(permixClient) + return ( +
+ + {check('post.create').toString()} + + {isReady.toString()} +
+ ) + } + + const { getByTestId } = render( + + + + + + + + ) + + expect(getByTestId('hydrate-create')).toHaveTextContent('true') + expect(getByTestId('hydrate-ready')).toHaveTextContent('false') + + permixClient.setup({ + post: { + create: true, + read: false, + }, + }) + + await waitFor(() => { + expect(getByTestId('hydrate-ready')).toHaveTextContent('true') + }) + }) + + it('replaces hydrated booleans when setup supplies new rules', async () => { + const permixServer = createPermix<{ + post: ['create', 'read'] + }>().setup({ + post: { + create: true, + read: false, + }, + }) + + const dehydrated = permixServer.dehydrate() + const permixClient = createPermix<{ + post: ['create', 'read'] + }>() + + function TestComponent() { + const { check } = usePermix(permixClient) + return ( +
+ + {check('post.create').toString()} + + + {check('post.read').toString()} + +
+ ) + } + + const { getByTestId } = render( + + + + + + ) + + expect(getByTestId('replace-create')).toHaveTextContent('true') + expect(getByTestId('replace-read')).toHaveTextContent('false') + + permixClient.setup({ + post: { + create: false, + read: true, + }, + }) + + await waitFor(() => { + expect(getByTestId('replace-create')).toHaveTextContent('false') + expect(getByTestId('replace-read')).toHaveTextContent('true') + }) + }) + it('should work with Check component', () => { const permix = createPermix<{ post: ['create'] - }>() - - permix.setup({ + }>().setup({ post: { create: true, }, @@ -71,12 +208,70 @@ describe('components', () => { expect(getByText(text)).toBeInTheDocument() }) + it('does not rerender Check when an unrelated rule changes', () => { + const permix = createPermix<{ + post: ['create', 'read'] + }>().setup({ + post: { + create: true, + read: false, + }, + }) + + const { Check } = createComponents(permix) + const onCheckRender = vi.fn() + const onChildRender = vi.fn() + + const Probe = React.memo(() => { + onChildRender() + return allowed + }) + Probe.displayName = 'Probe' + + const { queryByTestId } = render( + + + + + + + + ) + + expect(queryByTestId('create-probe')).toBeInTheDocument() + expect(onCheckRender).toHaveBeenCalledOnce() + expect(onChildRender).toHaveBeenCalledOnce() + + act(() => { + permix.setup({ + post: { + create: true, + read: true, + }, + }) + }) + + expect(queryByTestId('create-probe')).toBeInTheDocument() + expect(onCheckRender).toHaveBeenCalledOnce() + expect(onChildRender).toHaveBeenCalledOnce() + + act(() => { + permix.setup({ + post: { + create: false, + read: true, + }, + }) + }) + + expect(queryByTestId('create-probe')).not.toBeInTheDocument() + expect(onChildRender).toHaveBeenCalledOnce() + }) + it('should work with Check component and data', () => { const permix = createPermix<{ post: [{ name: 'edit'; type: { authorId: string } }] - }>() - - permix.setup({ + }>().setup({ post: { edit: (post) => post?.authorId === '1', }, @@ -124,9 +319,7 @@ describe('components', () => { it('should work with Check component and DOM rerender', async () => { const permix = createPermix<{ post: ['read'] - }>() - - permix.setup({ + }>().setup({ post: { read: false, }, @@ -164,9 +357,7 @@ describe('components', () => { it('should work with reverse prop', async () => { const permix = createPermix<{ post: ['create'] - }>() - - permix.setup({ + }>().setup({ post: { create: true, }, @@ -207,9 +398,7 @@ describe('components', () => { it('should validate ts props', () => { const permix = createPermix<{ post: ['create'] - }>() - - permix.setup({ + }>().setup({ post: { create: true, }, diff --git a/permix/src/react/components.tsx b/permix/src/react/components.tsx index 097740df..75f11ee0 100644 --- a/permix/src/react/components.tsx +++ b/permix/src/react/components.tsx @@ -6,10 +6,116 @@ import type { Definition, DehydratedState, Permix, + Rules, RulesPaths, } from '../core' +import { isSamePermixFamily, runCheck } from '../core' import type { PermixContext } from './hooks' -import { Context, usePermix, usePermixContext } from './hooks' +import { + Context, + readPermixContext, + usePermixContext, + usePermixSelector, +} from './hooks' +import { useEffectEvent } from './use-effect-event' +import { useLayoutEffect } from './use-isomorphic-layout-effect' + +function createSnapshotReader( + getPermix: () => Permix, + read: () => Pick, 'isReady' | 'rules'> +) { + let snapshot: PermixContext | null = null + + return () => { + const current = read() + const permix = getPermix() + if ( + !snapshot || + snapshot.permix !== permix || + snapshot.isReady !== current.isReady || + snapshot.rules !== current.rules + ) { + snapshot = { + permix, + isReady: current.isReady, + rules: current.rules, + } + } + return snapshot + } +} + +function createProviderContext( + permix: Permix +): PermixContext { + let current = permix + const getSnapshot = createSnapshotReader( + () => current, + () => ({ + isReady: current.isReady(), + rules: current.getRules(), + }) + ) + const subscribe = (onStoreChange: () => void) => { + const unsubSetup = permix.hook('setup', (instance) => { + current = instance + onStoreChange() + }) + const unsubReady = permix.hook('ready', (instance) => { + current = instance + onStoreChange() + }) + return () => { + unsubSetup() + unsubReady() + } + } + + return { + get permix() { + return getSnapshot().permix + }, + get isReady() { + return getSnapshot().isReady + }, + get rules() { + return getSnapshot().rules + }, + subscribe, + getSnapshot, + } +} + +function createHydrateContext( + parent: PermixContext, + state: DehydratedState +): PermixContext { + const getSnapshot = createSnapshotReader( + () => readPermixContext(parent).permix, + () => { + const snapshot = readPermixContext(parent) + return { + isReady: snapshot.isReady, + rules: snapshot.rules ?? (state as unknown as Rules), + } + } + ) + + return { + get permix() { + return getSnapshot().permix + }, + get isReady() { + return getSnapshot().isReady + }, + get rules() { + return getSnapshot().rules + }, + subscribe: (onStoreChange) => + parent.subscribe?.(onStoreChange) ?? (() => undefined), + getSnapshot, + } +} /** * Provides Permix context to the React component tree. @@ -19,56 +125,44 @@ import { Context, usePermix, usePermixContext } from './hooks' export function PermixProvider({ children, permix, + context, }: { children: React.ReactNode permix: Permix + context?: React.Context | null> }) { - const [context, setContext] = React.useState>(() => ({ - permix, - isReady: permix.isReady(), - rules: permix.getRules(), - })) - - React.useEffect(() => { - const syncRules = () => { - queueMicrotask(() => { - setContext((c) => ({ ...c, rules: permix.getRules() })) - }) - } - const syncReady = () => { - queueMicrotask(() => { - setContext((c) => ({ ...c, isReady: permix.isReady() })) - }) - } - const setup = permix.hook('setup', syncRules) - const ready = permix.hook('ready', syncReady) + const Ctx = context ?? (Context as React.Context | null>) + const value = React.useMemo(() => createProviderContext(permix), [permix]) - return () => { - setup() - ready() - } - }, [permix]) - - return {children} + return {children} } -export function PermixHydrate({ +export function PermixHydrate({ children, state, + context, }: { children: React.ReactNode - state: DehydratedState + state: DehydratedState + context?: React.Context | null> }) { - const { permix } = usePermixContext() + const Ctx = context ?? (Context as React.Context | null>) + const parent = usePermixContext(context) + + const hydrateEvent = useEffectEvent((nextState: DehydratedState) => { + parent.permix.hydrate(nextState) + }) + + useLayoutEffect(() => { + hydrateEvent(state) + }, [state]) - // Run before children render so `check()` can use hydrated booleans on the first pass. - // PermixProvider defers context updates from the `setup` hook to avoid setState during render. - // eslint-disable-next-line react/use-memo, react/void-use-memo - React.useMemo(() => { - permix.hydrate(state) - }, [permix, state]) + const value = React.useMemo( + () => createHydrateContext(parent, state), + [parent, state] + ) - return children + return {children} } export interface CheckProps> { @@ -84,7 +178,8 @@ export interface PermixComponents { } export function createComponents( - permix: Pick, 'getRules' | 'check'> + permix: Pick, 'getRules' | 'check'>, + context?: React.Context | null> ): PermixComponents { function Check

>({ children, @@ -93,9 +188,25 @@ export function createComponents( otherwise = null, reverse = false, }: CheckProps) { - const { check } = usePermix(permix) + const value = usePermixContext(context) + + if ( + process.env.NODE_ENV !== 'production' && + !isSamePermixFamily(value.permix, permix) + ) { + throw new Error( + '[Permix]: usePermix must receive the same instance passed to ' + ) + } + + const hasPermission = usePermixSelector(value, (snapshot) => + runCheck( + snapshot.permix, + snapshot.rules, + ...([path, data] as unknown as CheckArgs) + ) + ) - const hasPermission = check(...([path, data] as unknown as CheckArgs)) return reverse ? hasPermission ? otherwise diff --git a/permix/src/react/create-permix.test.tsx b/permix/src/react/create-permix.test.tsx new file mode 100644 index 00000000..3bc9c956 --- /dev/null +++ b/permix/src/react/create-permix.test.tsx @@ -0,0 +1,173 @@ +import { render, waitFor } from '@testing-library/react' +import * as React from 'react' +import { describe, expect, it } from 'vitest' +import '@testing-library/jest-dom/vitest' + +import { createPermix as createCore } from '../core' +import { createPermix } from './create-permix' + +describe('react factory contexts', () => { + it('creates a core instance and bound UI when called with a definition', () => { + const { + PermixProvider: BoundProvider, + usePermix: useBoundPermix, + Check, + } = createPermix( + createCore<{ + post: ['read'] + }>().setup({ + post: { + read: true, + }, + }) + ) + + function HookLabel() { + const { check, isReady } = useBoundPermix() + return ( + {`${isReady}:${check('post.read')}`} + ) + } + + const { getByTestId, getByText } = render( + + + + allowed + + + ) + + expect(getByTestId('hook')).toHaveTextContent('true:true') + expect(getByText('allowed')).toBeInTheDocument() + }) + + it('wraps an existing core instance', () => { + const permix = createCore<{ + post: ['read'] + }>().setup({ + post: { + read: true, + }, + }) + + const ui = createPermix(permix) + + expect(ui.permix).toBe(permix) + + function HookLabel() { + const { check } = ui.usePermix() + return {check('post.read').toString()} + } + + const { getByTestId } = render( + + + + ) + + expect(getByTestId('wrap')).toHaveTextContent('true') + }) + + it('keeps nested factory contexts independent', () => { + const postsUI = createPermix( + createCore<{ + post: ['read'] + }>().setup({ + post: { + read: true, + }, + }) + ) + const commentsUI = createPermix( + createCore<{ + comment: ['read'] + }>().setup({ + comment: { + read: false, + }, + }) + ) + + function Nested() { + const postsState = postsUI.usePermix() + const commentsState = commentsUI.usePermix() + return ( +

+ + {postsState.check('post.read').toString()} + + + {commentsState.check('comment.read').toString()} + + + post-ok + + + comment-ok + +
+ ) + } + + const { getByTestId, queryByTestId } = render( + + + + + + ) + + expect(getByTestId('post')).toHaveTextContent('true') + expect(getByTestId('comment')).toHaveTextContent('false') + expect(getByTestId('post-check')).toHaveTextContent('post-ok') + expect(queryByTestId('comment-check')).not.toBeInTheDocument() + }) + + it('hydrates through the factory overlay', async () => { + const server = createCore<{ + post: ['create'] + }>().setup({ + post: { + create: true, + }, + }) + const state = server.dehydrate() + + const { + permix: client, + PermixProvider: BoundProvider, + PermixHydrate: BoundHydrate, + usePermix: useBoundPermix, + } = createPermix<{ + post: ['create'] + }>() + + function Label() { + const { check, isReady } = useBoundPermix() + return ( + {`${check('post.create')}:${isReady}`} + ) + } + + const { getByTestId } = render( + + + + + ) + + expect(getByTestId('hydrate')).toHaveTextContent('true:false') + + client.setup({ + post: { + create: true, + }, + }) + + await waitFor(() => { + expect(getByTestId('hydrate')).toHaveTextContent('true:true') + }) + }) +}) diff --git a/permix/src/react/create-permix.tsx b/permix/src/react/create-permix.tsx new file mode 100644 index 00000000..95897706 --- /dev/null +++ b/permix/src/react/create-permix.tsx @@ -0,0 +1,76 @@ +import type { ReactNode } from 'react' + +import type { Definition, DehydratedState, Permix } from '../core' +import { createPermix as createPermixCore } from '../core' +import type { PermixComponents } from './components' +import { createComponents, PermixHydrate, PermixProvider } from './components' +import { createPermixContext, usePermix as usePermixFromContext } from './hooks' + +export interface CreatePermixResult { + permix: Permix + PermixProvider: (props: { children: ReactNode }) => ReactNode + PermixHydrate: (props: { + children: ReactNode + state: DehydratedState + }) => ReactNode + usePermix: () => { + check: Permix['check'] + explain: Permix['explain'] + isReady: boolean + } + Check: PermixComponents['Check'] +} + +/** + * Create a Permix instance with isolated React bindings. + * + * Call once at module scope, same as `createPermix` from `permix/next` or + * `permix/express`. Pass an existing core instance to wrap it instead of + * creating a new one. + * + * @link https://permix.letstri.dev/docs/integrations/react + */ +export function createPermix( + instance?: Permix +): CreatePermixResult { + const permix = instance ?? createPermixCore() + const context = createPermixContext() + const { Check } = createComponents(permix, context) + + function BoundProvider({ children }: { children: ReactNode }) { + return ( + + {children} + + ) + } + + function BoundHydrate({ + children, + state, + }: { + children: ReactNode + state: DehydratedState + }) { + return ( + + {children} + + ) + } + + function useBoundPermix() { + return usePermixFromContext(permix, context) + } + + BoundProvider.displayName = 'PermixProvider' + BoundHydrate.displayName = 'PermixHydrate' + + return { + permix, + PermixProvider: BoundProvider, + PermixHydrate: BoundHydrate, + usePermix: useBoundPermix, + Check, + } +} diff --git a/permix/src/react/hooks.test.tsx b/permix/src/react/hooks.test.tsx index b4c76628..24e69749 100644 --- a/permix/src/react/hooks.test.tsx +++ b/permix/src/react/hooks.test.tsx @@ -1,18 +1,17 @@ -import { render, renderHook, waitFor } from '@testing-library/react' +import { act, render, renderHook, waitFor } from '@testing-library/react' import * as React from 'react' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { createPermix } from '../core' -import { PermixProvider, usePermix } from './index' +import { PermixProvider } from './components' +import { usePermix } from './hooks' import '@testing-library/jest-dom/vitest' describe('permix react', () => { it('should work with custom hook', () => { const permix = createPermix<{ post: ['create', 'read'] - }>() - - permix.setup({ + }>().setup({ post: { create: true, read: false, @@ -31,12 +30,79 @@ describe('permix react', () => { expect(result.current.check('post.read')).toBe(false) }) + it('rerenders full usePermix consumers when any rule changes', () => { + const permix = createPermix<{ + post: ['create', 'read'] + }>().setup({ + post: { + create: true, + read: false, + }, + }) + + const onRender = vi.fn() + + function TestComponent() { + onRender() + const { check } = usePermix(permix) + return
{check('post.create').toString()}
+ } + + const { container } = render( + + + + ) + + expect(container.firstChild).toHaveTextContent('true') + expect(onRender).toHaveBeenCalledOnce() + + act(() => { + permix.setup({ + post: { + create: true, + read: true, + }, + }) + }) + + expect(container.firstChild).toHaveTextContent('true') + expect(onRender).toHaveBeenCalledTimes(2) + }) + + it('reads ready state on the first render when setup ran before subscribe', () => { + const permix = createPermix<{ + post: ['create'] + }>().setup({ + post: { + create: true, + }, + }) + + function TestComponent() { + const { isReady, check } = usePermix(permix) + return ( +
+ {isReady.toString()}:{check('post.create').toString()} +
+ ) + } + + const { container } = render( + + + + + + ) + + expect(container.firstChild).toHaveTextContent('true:true') + }) + it('should work with DOM rerender', async () => { const permix = createPermix<{ post: [{ name: 'create'; type: { id: string } }, 'read'] - }>() - - permix.setup({ + }>().setup({ post: { create: (post) => post?.id === '1', read: false, @@ -122,4 +188,52 @@ describe('permix react', () => { expect(() => render()).toThrow() }) + + it('runs UI check through the instance so check hooks fire', () => { + const permix = createPermix<{ + post: ['create'] + }>().setup({ + post: { create: true }, + }) + + const onCheck = vi.fn() + permix.hook('check', onCheck) + + const { result } = renderHook(() => usePermix(permix), { + wrapper: ({ children }) => ( + {children} + ), + }) + + expect(result.current.check('post.create')).toBe(true) + expect(onCheck).toHaveBeenCalledOnce() + expect(onCheck).toHaveBeenCalledWith({ + path: 'post.create', + allowed: true, + reasons: [], + }) + }) + + it('exposes explain() for UI denial reasons', () => { + const permix = createPermix<{ + post: ['create'] + }>().setup({ + post: { + create: () => ({ allow: false, reason: 'not an author' }), + }, + }) + + const { result } = renderHook(() => usePermix(permix), { + wrapper: ({ children }) => ( + {children} + ), + }) + + expect(result.current.check('post.create')).toBe(false) + expect(result.current.explain('post.create')).toStrictEqual({ + allowed: false, + path: 'post.create', + reasons: ['not an author'], + }) + }) }) diff --git a/permix/src/react/hooks.ts b/permix/src/react/hooks.ts index 720da34d..ba359de9 100644 --- a/permix/src/react/hooks.ts +++ b/permix/src/react/hooks.ts @@ -1,26 +1,57 @@ import * as React from 'react' import type { Definition, Permix, Rules } from '../core' -import { createCheck } from '../core' +import { isSamePermixFamily, runCheck, runExplain } from '../core' export interface PermixContext { permix: Permix isReady: boolean rules: Rules | null + subscribe?: (onStoreChange: () => void) => () => void + getSnapshot?: () => PermixContext } -export const Context = React.createContext>(null!) +export function createPermixContext() { + return React.createContext | null>(null) +} + +export const Context = createPermixContext() -export function usePermixContext() { - const context = React.useContext(Context) +export function usePermixContext( + context?: React.Context | null> +): PermixContext { + const value = React.useContext( + context ?? (Context as React.Context | null>) + ) - if (!context) { + if (!value) { throw new Error( '[Permix]: Looks like you forgot to wrap your app with ' ) } - return context + return value +} + +const noop = () => undefined +const noopSubscribe = () => noop +const selectSnapshot = (value: PermixContext) => value + +export function readPermixContext( + value: PermixContext +): PermixContext { + return value.getSnapshot?.() ?? value +} + +export function usePermixSelector( + value: PermixContext, + selector: (snapshot: PermixContext) => S +): S { + return React.useSyncExternalStore( + value.subscribe ?? noopSubscribe, + () => selector(readPermixContext(value)), + () => selector(readPermixContext(value)) + ) } /** @@ -29,17 +60,34 @@ export function usePermixContext() { * @link https://permix.letstri.dev/docs/integrations/react */ export function usePermix( - permix: Pick, 'getRules' | 'check'> + permix: Pick, 'getRules' | 'check'>, + context?: React.Context | null> ) { - const { isReady, rules } = usePermixContext() + const value = usePermixContext(context) + const { + isReady, + rules, + permix: provided, + } = usePermixSelector(value, selectSnapshot) + + if ( + process.env.NODE_ENV !== 'production' && + !isSamePermixFamily(provided, permix) + ) { + throw new Error( + '[Permix]: usePermix must receive the same instance passed to ' + ) + } const check: Permix['check'] = React.useCallback( - (...args) => - createCheck(() => (rules ?? permix.getRules()) as Rules | null)( - ...args - ), - [rules, permix] + (...args) => runCheck(provided, rules, ...args), + [rules, provided] + ) + + const explain: Permix['explain'] = React.useCallback( + (...args) => runExplain(provided, rules, ...args), + [rules, provided] ) - return { check, isReady } + return { check, explain, isReady } } diff --git a/permix/src/react/index.ts b/permix/src/react/index.ts index 0852278f..cee1c88e 100644 --- a/permix/src/react/index.ts +++ b/permix/src/react/index.ts @@ -1,4 +1,4 @@ -export { createComponents, PermixHydrate, PermixProvider } from './components' +export { createPermix } from './create-permix' +export type { CreatePermixResult } from './create-permix' export type { CheckProps, PermixComponents } from './components' -export { usePermix } from './hooks' export type { PermixContext } from './hooks' diff --git a/permix/src/react/use-effect-event-compat.test.tsx b/permix/src/react/use-effect-event-compat.test.tsx new file mode 100644 index 00000000..639ae5f1 --- /dev/null +++ b/permix/src/react/use-effect-event-compat.test.tsx @@ -0,0 +1,29 @@ +import { renderHook } from '@testing-library/react' +import * as React from 'react' +import { describe, expect, it } from 'vitest' + +import { useEffectEvent } from './use-effect-event' + +describe('useEffectEvent compatibility', () => { + it('keeps a stable identity while reading the latest callback', () => { + const calls: string[] = [] + const { rerender } = renderHook( + ({ value, prefix }: { value: string; prefix: string }) => { + const onValue = useEffectEvent((next: string) => { + calls.push(`${prefix}:${next}`) + }) + + React.useEffect(() => { + onValue(value) + }, [value]) + + return onValue + }, + { initialProps: { value: 'light', prefix: 'initial' } } + ) + + rerender({ value: 'dark', prefix: 'latest' }) + + expect(calls).toStrictEqual(['initial:light', 'latest:dark']) + }) +}) diff --git a/permix/src/react/use-effect-event.ts b/permix/src/react/use-effect-event.ts new file mode 100644 index 00000000..9007be60 --- /dev/null +++ b/permix/src/react/use-effect-event.ts @@ -0,0 +1,29 @@ +import * as React from 'react' + +type EffectEventHook = ( + callback: (...arguments_: Arguments) => Result +) => (...arguments_: Arguments) => Result + +function useEffectEventFallback( + callback: (...arguments_: Arguments) => Result +): (...arguments_: Arguments) => Result { + const callbackRef = React.useRef(callback) + + React.useInsertionEffect(() => { + callbackRef.current = callback + }, [callback]) + + return React.useCallback( + (...arguments_: Arguments) => callbackRef.current(...arguments_), + [] + ) +} + +const nativeUseEffectEvent = ( + React as typeof React & { + useEffectEvent?: EffectEventHook + } +).useEffectEvent + +export const useEffectEvent: EffectEventHook = + nativeUseEffectEvent ?? useEffectEventFallback diff --git a/permix/src/react/use-isomorphic-layout-effect.ts b/permix/src/react/use-isomorphic-layout-effect.ts new file mode 100644 index 00000000..e91de35e --- /dev/null +++ b/permix/src/react/use-isomorphic-layout-effect.ts @@ -0,0 +1,4 @@ +import * as React from 'react' + +export const useLayoutEffect = + 'window' in globalThis ? React.useLayoutEffect : React.useEffect diff --git a/permix/src/server/index.ts b/permix/src/server/index.ts index 60dafabb..60a909d0 100644 --- a/permix/src/server/index.ts +++ b/permix/src/server/index.ts @@ -1 +1,3 @@ +export { createRequestKernel, propertyBagStore, withContextKey } from './kernel' +export type { RequestKernel, RequestKernelStore } from './kernel' export * from './permix' diff --git a/permix/src/server/kernel.test.ts b/permix/src/server/kernel.test.ts new file mode 100644 index 00000000..4bd09823 --- /dev/null +++ b/permix/src/server/kernel.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' + +import { createPermix as createCore } from '../core' +import { createRequestKernel, propertyBagStore, withContextKey } from './kernel' + +describe(createRequestKernel, () => { + it('attaches a ready instance and isolates context keys', () => { + const store = new Map< + string | symbol, + ReturnType> + >() + + const kernel = withContextKey((resolveKey) => + createRequestKernel<{ post: ['read'] }, object>(resolveKey, { + get: () => store.get(resolveKey()), + set: (_container, instance) => { + store.set(resolveKey(), instance) + }, + }) + ) + + const container = {} + kernel.attach(container, { post: { read: true } }) + + expect(kernel.getOrThrow(container).check('post.read')).toBe(true) + expect(kernel.getRules(container)?.post.read).toBe(true) + + kernel.contextKey('other') + expect(kernel.get(container)).toBeNull() + }) + + it('stores the instance on a property bag', () => { + const kernel = withContextKey((resolveKey) => + createRequestKernel<{ post: ['read'] }, object>( + resolveKey, + propertyBagStore(resolveKey) + ) + ) + + const container = {} + kernel.attach(container, { post: { read: true } }) + expect(kernel.getOrThrow(container).check('post.read')).toBe(true) + }) +}) diff --git a/permix/src/server/kernel.ts b/permix/src/server/kernel.ts new file mode 100644 index 00000000..fdb7d6d9 --- /dev/null +++ b/permix/src/server/kernel.ts @@ -0,0 +1,115 @@ +import type { Permix as PermixCore } from '../core' +import { + createHooks, + createPermix as createPermixCore, + createTemplate, + PermixNotFoundError, +} from '../core' +import type { Definition } from '../core/definitions' +import type { PermixHooks, Rules, RulesPaths } from '../core/permix' + +/** + * How a request-scoped kernel reads and writes the Permix instance on a + * framework store (Express `req`, Hono `c`, Fetch `Request`, …). + */ +export interface RequestKernelStore { + get: (store: Store) => PermixCore | null | undefined + set: (store: Store, instance: PermixCore) => void +} + +/** + * Read/write the instance as a property on the request object. Frameworks + * without a typed bag (Express, Node `IncomingMessage`, Fetch `Request`) + * use this; Hono uses `c.get`/`c.set` instead. + */ +export function propertyBagStore( + resolveKey: () => string | symbol +): RequestKernelStore { + return { + get: (container) => + (container as unknown as Record>)[ + resolveKey() + ], + set: (container, instance) => { + ;(container as unknown as Record>)[ + resolveKey() + ] = instance + }, + } +} + +/** + * Shared get / attach / hook loop for HTTP adapters. Framework files wrap + * this with middleware signatures and `onForbidden` handlers. + */ +export function createRequestKernel( + resolveKey: () => string | symbol, + store: RequestKernelStore +) { + const hooks = createHooks>() + + function get(container: Store): PermixCore | null { + return store.get(container) ?? null + } + + function getOrThrow(container: Store): PermixCore { + const instance = get(container) + if (!instance) { + throw new PermixNotFoundError(resolveKey()) + } + return instance + } + + function attach(container: Store, rules: Rules): PermixCore { + const instance = createPermixCore().setup(rules) + instance.hook('check', (context) => { + hooks.callHook('check', context) + }) + store.set(container, instance) + return instance + } + + function getRules(container: Store): Rules | null { + return get(container)?.getRules() ?? null + } + + function template(rules: Rules | ((param: T) => Rules)) { + return createTemplate(rules) + } + + return { + get, + getOrThrow, + attach, + getRules, + template, + hook: hooks.hook, + hookOnce: hooks.hookOnce, + get key() { + return resolveKey() + }, + $inferDefinition: undefined as unknown as D, + $inferPath: undefined as unknown as RulesPaths, + } +} + +export type RequestKernel = ReturnType< + typeof createRequestKernel +> + +/** + * Mutable context key (default `Symbol('permix')`) shared by HTTP adapters. + */ +export function withContextKey( + build: (resolveKey: () => string | symbol) => T +): T & { contextKey: (newKey: string | symbol) => T } { + let key: string | symbol = Symbol('permix') + const permix = build(() => key) + + return Object.assign(permix, { + contextKey(newKey: string | symbol) { + key = newKey + return permix + }, + }) +} diff --git a/permix/src/server/permix.ts b/permix/src/server/permix.ts index f3c145d7..420a3666 100644 --- a/permix/src/server/permix.ts +++ b/permix/src/server/permix.ts @@ -1,15 +1,10 @@ import type { Permix as PermixCore } from '../core' -import { - createCheckContext, - createHooks, - createPermix as createPermixCore, - createTemplate, - PermixNotFoundError, -} from '../core' +import { PermixNotFoundError, withDenialReasons } from '../core' import type { CheckArgs, CheckContext } from '../core/check' import type { Definition } from '../core/definitions' -import type { PermixHooks, Rules, RulesPaths } from '../core/permix' +import type { Rules } from '../core/permix' import type { MaybePromise } from '../utils' +import { createRequestKernel, propertyBagStore, withContextKey } from './kernel' export type NextFunction = () => MaybePromise @@ -37,95 +32,6 @@ export interface PermixOptions { ) => MaybePromise } -function buildPermix( - resolveKey: () => string | symbol, - options: PermixOptions = {} -) { - const onForbidden = - options.onForbidden ?? - (() => - new Response(JSON.stringify({ error: 'Forbidden' }), { - status: 403, - headers: { 'Content-Type': 'application/json' }, - })) - - const hooks = createHooks>() - - function get(req: Request): PermixCore | null { - const instance = (req as any)[resolveKey()] as PermixCore | undefined - return instance ?? null - } - - function getOrThrow(req: Request): PermixCore { - const instance = get(req) - if (!instance) { - throw new PermixNotFoundError(resolveKey()) - } - return instance - } - - function setupMiddleware( - callbackOrRules: - | ((context: MiddlewareContext) => MaybePromise>) - | Rules - ): Middleware { - return async (req, next) => { - const rules = - typeof callbackOrRules === 'function' - ? await callbackOrRules({ req, next }) - : callbackOrRules - const instance = createPermixCore(rules) - instance.hook('check', (context) => { - hooks.callHook('check', context) - }) - ;(req as any)[resolveKey()] = instance - return await next() - } - } - - const checkMiddleware: (...args: CheckArgs) => Middleware = - (...args) => - async (req, next) => { - const permix = get(req) - - if (!permix) { - throw new PermixNotFoundError(resolveKey()) - } - - const allowed = permix.check(...args) - - if (!allowed) { - return await onForbidden({ req, next, ...createCheckContext(...args) }) - } - - return await next() - } - - function getRules(req: Request): Rules | null { - return get(req)?.getRules() ?? null - } - - function template(rules: Rules | ((param: T) => Rules)) { - return createTemplate(rules) - } - - return { - setupMiddleware, - checkMiddleware, - template, - get, - getOrThrow, - getRules, - hook: hooks.hook, - hookOnce: hooks.hookOnce, - get key() { - return resolveKey() - }, - $inferDefinition: undefined as unknown as D, - $inferPath: undefined as unknown as RulesPaths, - } -} - /** * Create a middleware factory that wires Permix into fetch-style request handlers. * @@ -161,14 +67,84 @@ function buildPermix( export function createPermix( options: PermixOptions = {} ) { - let key: string | symbol = Symbol('permix') - const permix = buildPermix(() => key, options) - - return Object.assign(permix, { - contextKey(newKey: string | symbol) { - key = newKey - return permix - }, + const onForbidden = + options.onForbidden ?? + (() => + new Response(JSON.stringify({ error: 'Forbidden' }), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + })) + + return withContextKey((resolveKey) => { + const kernel = createRequestKernel( + resolveKey, + propertyBagStore(resolveKey) + ) + + function get(req: Request): PermixCore | null { + return kernel.get(req) + } + + function getOrThrow(req: Request): PermixCore { + return kernel.getOrThrow(req) + } + + function getRules(req: Request): Rules | null { + return kernel.getRules(req) + } + + function setupMiddleware( + callbackOrRules: + | ((context: MiddlewareContext) => MaybePromise>) + | Rules + ): Middleware { + return async (req, next) => { + const rules = + typeof callbackOrRules === 'function' + ? await callbackOrRules({ req, next }) + : callbackOrRules + kernel.attach(req, rules) + return await next() + } + } + + const checkMiddleware: (...args: CheckArgs) => Middleware = + (...args) => + async (req, next) => { + const permix = get(req) + + if (!permix) { + throw new PermixNotFoundError(resolveKey()) + } + + const allowed = permix.check(...args) + + if (!allowed) { + return await onForbidden({ + req, + next, + ...withDenialReasons(permix, args), + }) + } + + return await next() + } + + return { + setupMiddleware, + checkMiddleware, + template: kernel.template, + get, + getOrThrow, + getRules, + hook: kernel.hook, + hookOnce: kernel.hookOnce, + get key() { + return kernel.key + }, + $inferDefinition: kernel.$inferDefinition, + $inferPath: kernel.$inferPath, + } }) } diff --git a/permix/src/solid/components.test.tsx b/permix/src/solid/components.test.tsx index bbe8f56a..bb04ce61 100644 --- a/permix/src/solid/components.test.tsx +++ b/permix/src/solid/components.test.tsx @@ -2,17 +2,16 @@ import { render, waitFor } from '@solidjs/testing-library' import { describe, expect, it } from 'vitest' import { createPermix, PermixRuleNotDefinedError } from '../core' -import { createComponents, PermixHydrate, PermixProvider } from './components' +import { createComponents } from './components' import { usePermix } from './hooks' +import { PermixHydrate, PermixProvider } from './provider' import '@testing-library/jest-dom/vitest' describe('components', () => { it('should check hydration', async () => { const permixServer = createPermix<{ post: ['create', 'read'] - }>() - - permixServer.setup({ + }>().setup({ post: { create: true, read: false, @@ -41,12 +40,45 @@ describe('components', () => { expect(container.firstChild).toHaveTextContent('true') }) + it('uses dehydrated rules on the first render', () => { + const permixServer = createPermix<{ + post: ['create', 'read'] + }>().setup({ + post: { + create: true, + read: false, + }, + }) + + const dehydrated = permixServer.dehydrate() + const permixClient = createPermix<{ + post: ['create', 'read'] + }>() + + const TestComponent = () => { + const { check, isReady } = usePermix(permixClient) + return ( +
+ {check('post.create').toString()}:{isReady().toString()} +
+ ) + } + + const { container } = render(() => ( + + + + + + )) + + expect(container.firstChild).toHaveTextContent('true:false') + }) + it('should work with Check component', () => { const permix = createPermix<{ post: ['create'] - }>() - - permix.setup({ + }>().setup({ post: { create: true, }, @@ -74,9 +106,7 @@ describe('components', () => { it('should work with Check component and data', () => { const permix = createPermix<{ post: [{ name: 'edit'; type: { authorId: string } }] - }>() - - permix.setup({ + }>().setup({ post: { edit: (post) => post?.authorId === '1', }, @@ -124,9 +154,7 @@ describe('components', () => { it('should work with Check component and DOM rerender', async () => { const permix = createPermix<{ post: ['read'] - }>() - - permix.setup({ + }>().setup({ post: { read: false, }, @@ -164,9 +192,7 @@ describe('components', () => { it('should work with reverse prop', async () => { const permix = createPermix<{ post: ['create'] - }>() - - permix.setup({ + }>().setup({ post: { create: true, }, @@ -207,9 +233,7 @@ describe('components', () => { it('should validate ts props', () => { const permix = createPermix<{ post: ['create'] - }>() - - permix.setup({ + }>().setup({ post: { create: true, }, diff --git a/permix/src/solid/components.tsx b/permix/src/solid/components.tsx index cf12e87a..356898dc 100644 --- a/permix/src/solid/components.tsx +++ b/permix/src/solid/components.tsx @@ -1,67 +1,15 @@ -import type { JSX } from 'solid-js' -import { - createEffect, - createMemo, - createRenderEffect, - onCleanup, -} from 'solid-js' -import { createStore } from 'solid-js/store' +import type { Context as SolidContext, JSX } from 'solid-js' +import { createMemo } from 'solid-js' import type { CheckArgs, DataAtPath, Definition, - DehydratedState, Permix, RulesPaths, } from '../core' import type { PermixContext } from './hooks' -import { Context, usePermix, usePermixContext } from './hooks' - -/** - * Provides Permix context to the Solid component tree. - * - * @link https://permix.letstri.dev/docs/integrations/solid - */ -export function PermixProvider(props: { - children: JSX.Element - permix: Permix -}): JSX.Element { - const [context, setContext] = createStore>({ - permix: props.permix, - isReady: props.permix.isReady(), - rules: props.permix.getRules(), - }) - - createEffect(() => { - const setup = props.permix.hook('setup', () => { - setContext('rules', props.permix.getRules()) - }) - const ready = props.permix.hook('ready', () => { - setContext('isReady', props.permix.isReady()) - }) - - onCleanup(() => { - setup() - ready() - }) - }) - - return {props.children} -} - -export function PermixHydrate(props: { - children: JSX.Element - state: DehydratedState -}) { - const context = usePermixContext() - - createRenderEffect(() => { - context.permix.hydrate(props.state) - }) - - return props.children -} +import { usePermix } from './hooks' export interface CheckProps> { path: P @@ -76,14 +24,15 @@ export interface PermixComponents { } export function createComponents( - permix: Pick, 'getRules' | 'check'> + permix: Pick, 'getRules' | 'check'>, + context?: SolidContext | null> ): PermixComponents { function Check

>( props: CheckProps ): JSX.Element { - const context = usePermix(permix) + const bound = usePermix(permix, context) const hasPermission = createMemo(() => - context.check(...([props.path, props.data] as unknown as CheckArgs)) + bound.check(...([props.path, props.data] as unknown as CheckArgs)) ) return ( diff --git a/permix/src/solid/create-permix.test.tsx b/permix/src/solid/create-permix.test.tsx new file mode 100644 index 00000000..58ac83e3 --- /dev/null +++ b/permix/src/solid/create-permix.test.tsx @@ -0,0 +1,91 @@ +import { render } from '@solidjs/testing-library' +import { describe, expect, it } from 'vitest' + +import { createPermix as createCore } from '../core' +import { createPermix } from './create-permix' + +describe('solid factory contexts', () => { + it('creates bound UI without a permix prop', () => { + const { + PermixProvider: BoundProvider, + usePermix: useBoundPermix, + Check, + } = createPermix( + createCore<{ + post: ['read'] + }>().setup({ + post: { read: true }, + }) + ) + + function HookLabel() { + const { check, isReady } = useBoundPermix() + return ( + {`${isReady()}:${check('post.read')}`} + ) + } + + const { getByTestId, getByText } = render(() => ( + + + + allowed + + + )) + + expect(getByTestId('hook')).toHaveTextContent('true:true') + expect(getByText('allowed')).toBeTruthy() + }) + + it('keeps nested factory contexts independent', () => { + const postsUI = createPermix( + createCore<{ + post: ['read'] + }>().setup({ + post: { read: true }, + }) + ) + const commentsUI = createPermix( + createCore<{ + comment: ['read'] + }>().setup({ + comment: { read: false }, + }) + ) + + function Nested() { + const postsState = postsUI.usePermix() + const commentsState = commentsUI.usePermix() + return ( +

+ + {postsState.check('post.read').toString()} + + + {commentsState.check('comment.read').toString()} + + + post-ok + + + comment-ok + +
+ ) + } + + const { getByTestId, queryByTestId } = render(() => ( + + + + + + )) + + expect(getByTestId('post')).toHaveTextContent('true') + expect(getByTestId('comment')).toHaveTextContent('false') + expect(getByTestId('post-check')).toHaveTextContent('post-ok') + expect(queryByTestId('comment-check')).toBeNull() + }) +}) diff --git a/permix/src/solid/create-permix.tsx b/permix/src/solid/create-permix.tsx new file mode 100644 index 00000000..79abb540 --- /dev/null +++ b/permix/src/solid/create-permix.tsx @@ -0,0 +1,123 @@ +import type { JSX } from 'solid-js' +import { createRenderEffect, createSignal, onCleanup, untrack } from 'solid-js' + +import type { Definition, DehydratedState, Permix, Rules } from '../core' +import { createPermix as createPermixCore } from '../core' +import type { PermixComponents } from './components' +import { createComponents } from './components' +import type { PermixContext } from './hooks' +import { + createPermixContext, + usePermix as usePermixFromContext, + usePermixContext, +} from './hooks' + +export interface CreatePermixResult { + permix: Permix + PermixProvider: (props: { children: JSX.Element }) => JSX.Element + PermixHydrate: (props: { + children: JSX.Element + state: DehydratedState + }) => JSX.Element + usePermix: () => { + check: Permix['check'] + explain: Permix['explain'] + isReady: () => boolean + } + Check: PermixComponents['Check'] +} + +/** + * Create a Permix instance with isolated Solid bindings. + * + * Call once at module scope. The bound provider does not take a `permix` + * prop. `usePermix()` takes no instance argument. + * + * @link https://permix.letstri.dev/docs/integrations/solid + */ +export function createPermix( + instance?: Permix +): CreatePermixResult { + const permix = instance ?? createPermixCore() + const context = createPermixContext() + const { Check } = createComponents(permix, context) + + function BoundProvider(props: { children: JSX.Element }) { + const [isReady, setIsReady] = createSignal(permix.isReady()) + const [rules, setRules] = createSignal(permix.getRules()) + const [current, setCurrent] = createSignal(permix) + + const value: PermixContext = { + get permix() { + return current() + }, + get isReady() { + return isReady() + }, + get rules() { + return rules() + }, + } + + createRenderEffect(() => { + const setup = permix.hook('setup', (next) => { + setCurrent(() => next) + setRules(() => next.getRules()) + setIsReady(next.isReady()) + }) + const ready = permix.hook('ready', (next) => { + setCurrent(() => next) + setRules(() => next.getRules()) + setIsReady(next.isReady()) + }) + + onCleanup(() => { + setup() + ready() + }) + }) + + // oxlint-disable-next-line react/jsx-no-constructed-context-values + return {props.children} + } + + function BoundHydrate(props: { + children: JSX.Element + state: DehydratedState + }) { + const parent = usePermixContext(context) + const nested: PermixContext = { + get permix() { + return parent.permix + }, + get isReady() { + return parent.isReady + }, + get rules() { + return parent.rules ?? (props.state as unknown as Rules) + }, + } + + createRenderEffect(() => { + const next = props.state + untrack(() => { + parent.permix.hydrate(next) + }) + }) + + // oxlint-disable-next-line react/jsx-no-constructed-context-values + return {props.children} + } + + function useBoundPermix() { + return usePermixFromContext(permix, context) + } + + return { + permix, + PermixProvider: BoundProvider, + PermixHydrate: BoundHydrate, + usePermix: useBoundPermix, + Check, + } +} diff --git a/permix/src/solid/hooks.test.tsx b/permix/src/solid/hooks.test.tsx index fb84133b..21e89f9b 100644 --- a/permix/src/solid/hooks.test.tsx +++ b/permix/src/solid/hooks.test.tsx @@ -2,16 +2,15 @@ import { render, renderHook, waitFor } from '@solidjs/testing-library' import { describe, expect, it } from 'vitest' import { createPermix } from '../core' -import { PermixProvider, usePermix } from './index' +import { usePermix } from './hooks' +import { PermixProvider } from './provider' import '@testing-library/jest-dom/vitest' describe('permix solid', () => { it('should work with custom hook', () => { const permix = createPermix<{ post: ['create', 'read'] - }>() - - permix.setup({ + }>().setup({ post: { create: true, read: false, @@ -30,12 +29,37 @@ describe('permix solid', () => { expect(result.check('post.read')).toBe(false) }) + it('reads ready state on the first render when setup ran before subscribe', () => { + const permix = createPermix<{ + post: ['create'] + }>().setup({ + post: { + create: true, + }, + }) + + const TestComponent = () => { + const { isReady, check } = usePermix(permix) + return ( +
+ {isReady().toString()}:{check('post.create').toString()} +
+ ) + } + + const { container } = render(() => , { + wrapper: (props) => ( + {props.children} + ), + }) + + expect(container.firstChild).toHaveTextContent('true:true') + }) + it('should work with DOM rerender', async () => { const permix = createPermix<{ post: [{ name: 'create'; type: { id: string } }, 'read'] - }>() - - permix.setup({ + }>().setup({ post: { create: (post) => post?.id === '1', read: false, @@ -121,4 +145,27 @@ describe('permix solid', () => { expect(() => render(() => )).toThrow() }) + + it('exposes explain() for UI denial reasons', () => { + const permix = createPermix<{ + post: ['create'] + }>().setup({ + post: { + create: () => ({ allow: false, reason: 'not an author' }), + }, + }) + + const { result } = renderHook(() => usePermix(permix), { + wrapper: (props) => ( + {props.children} + ), + }) + + expect(result.check('post.create')).toBe(false) + expect(result.explain('post.create')).toStrictEqual({ + allowed: false, + path: 'post.create', + reasons: ['not an author'], + }) + }) }) diff --git a/permix/src/solid/hooks.ts b/permix/src/solid/hooks.ts index 4cd74a6b..a9da0cad 100644 --- a/permix/src/solid/hooks.ts +++ b/permix/src/solid/hooks.ts @@ -1,26 +1,35 @@ import { createContext, useContext } from 'solid-js' +import type { Context as SolidContext } from 'solid-js' import type { Definition, Permix, Rules } from '../core' -import { createCheck } from '../core' +import { runCheck, runExplain } from '../core' export interface PermixContext { permix: Permix - isReady: boolean - rules: Rules | null + readonly isReady: boolean + readonly rules: Rules | null +} + +export function createPermixContext() { + return createContext | null>(null) } export const Context = createContext>(null!) -export function usePermixContext() { - const context = useContext(Context) +export function usePermixContext( + context?: SolidContext | null> +) { + const value = useContext( + context ?? (Context as SolidContext | null>) + ) - if (!context) { + if (!value) { throw new Error( '[Permix]: Looks like you forgot to wrap your app with ' ) } - return context + return value } /** @@ -29,14 +38,16 @@ export function usePermixContext() { * @link https://permix.letstri.dev/docs/integrations/solid */ export function usePermix( - permix: Pick, 'getRules' | 'check'> + _permix?: Pick, 'getRules' | 'check'>, + context?: SolidContext | null> ) { - const context = usePermixContext() + const value = usePermixContext(context) const check: Permix['check'] = (...args) => - createCheck( - () => (context.rules ?? permix.getRules()) as Rules | null - )(...args) + runCheck(value.permix, value.rules, ...args) + + const explain: Permix['explain'] = (...args) => + runExplain(value.permix, value.rules, ...args) - return { check, isReady: () => context.isReady } + return { check, explain, isReady: () => value.isReady } } diff --git a/permix/src/solid/index.ts b/permix/src/solid/index.ts index 0852278f..cee1c88e 100644 --- a/permix/src/solid/index.ts +++ b/permix/src/solid/index.ts @@ -1,4 +1,4 @@ -export { createComponents, PermixHydrate, PermixProvider } from './components' +export { createPermix } from './create-permix' +export type { CreatePermixResult } from './create-permix' export type { CheckProps, PermixComponents } from './components' -export { usePermix } from './hooks' export type { PermixContext } from './hooks' diff --git a/permix/src/solid/provider.tsx b/permix/src/solid/provider.tsx new file mode 100644 index 00000000..3f882045 --- /dev/null +++ b/permix/src/solid/provider.tsx @@ -0,0 +1,87 @@ +import type { JSX } from 'solid-js' +import { createRenderEffect, createSignal, onCleanup, untrack } from 'solid-js' + +import type { Definition, DehydratedState, Permix, Rules } from '../core' +import type { PermixContext } from './hooks' +import { Context, usePermixContext } from './hooks' + +/** + * Provides Permix context to the Solid component tree. + * + * Frozen rule snapshots cannot live in a Solid store (deep unwrap loops), + * so readiness and rules are signals with getters on the context object. + * + * @link https://permix.letstri.dev/docs/integrations/solid + */ +export function PermixProvider(props: { + children: JSX.Element + permix: Permix +}): JSX.Element { + const [isReady, setIsReady] = createSignal(props.permix.isReady()) + const [rules, setRules] = createSignal(props.permix.getRules()) + const [instance, setInstance] = createSignal(props.permix) + + const context: PermixContext = { + get permix() { + return instance() + }, + get isReady() { + return isReady() + }, + get rules() { + return rules() + }, + } + + createRenderEffect(() => { + const setup = props.permix.hook('setup', (next) => { + setInstance(() => next) + setRules(() => next.getRules()) + setIsReady(next.isReady()) + }) + const ready = props.permix.hook('ready', (next) => { + setInstance(() => next) + setRules(() => next.getRules()) + setIsReady(next.isReady()) + }) + + onCleanup(() => { + setup() + ready() + }) + }) + + // Solid setup runs once; getters read signals so this object is stable. + // oxlint-disable-next-line react/jsx-no-constructed-context-values + return {props.children} +} + +export function PermixHydrate(props: { + children: JSX.Element + state: DehydratedState +}) { + const parent = usePermixContext() + const overlay = () => props.state as unknown as Rules + + const context: PermixContext = { + get permix() { + return parent.permix + }, + get isReady() { + return parent.isReady + }, + get rules() { + return parent.rules ?? overlay() + }, + } + + createRenderEffect(() => { + const next = props.state + untrack(() => { + parent.permix.hydrate(next) + }) + }) + + // oxlint-disable-next-line react/jsx-no-constructed-context-values + return {props.children} +} diff --git a/permix/src/standard-schema/errors.ts b/permix/src/standard-schema/errors.ts new file mode 100644 index 00000000..9880ec9b --- /dev/null +++ b/permix/src/standard-schema/errors.ts @@ -0,0 +1,53 @@ +import { PermixError } from '../core/errors' +import type { StandardSchemaV1Issue } from '../core/standard-schema' + +export class PermixInvalidActionsError extends PermixError { + constructor() { + super('`actions` must be a non-empty array of strings.') + this.name = 'PermixInvalidActionsError' + } +} + +export class PermixInvalidSchemaMapError extends PermixError { + key: string + + constructor(key: string) { + super( + `Invalid standard-schema map value at "${key}". Expected a Standard Schema, entity(schema, actions), or an action name tuple.` + ) + this.name = 'PermixInvalidSchemaMapError' + this.key = key + } +} + +/** + * Thrown when {@link import('./permix').CreateStandardSchemaPermixOptions.validate} + * is `'throw'` and `check()` data fails the entity schema. + */ +export class PermixValidationError extends PermixError { + path: string + issues: readonly StandardSchemaV1Issue[] + + constructor(path: string, issues: readonly StandardSchemaV1Issue[]) { + super(`Data for "${path}" failed schema validation.`) + this.name = 'PermixValidationError' + this.path = path + this.issues = issues + } +} + +/** + * Thrown when a schema's Standard Schema `validate` returns a Promise. + * `permix.check()` is synchronous. + */ +export class PermixAsyncValidationError extends PermixError { + path: string + + constructor(path: string) { + super( + `Schema validation for "${path}" is asynchronous. permix.check() is synchronous; use a sync schema.` + ) + this.name = 'PermixAsyncValidationError' + this.path = path + } +} diff --git a/permix/src/standard-schema/index.ts b/permix/src/standard-schema/index.ts new file mode 100644 index 00000000..1bb0acc7 --- /dev/null +++ b/permix/src/standard-schema/index.ts @@ -0,0 +1,2 @@ +export * from './errors' +export * from './permix' diff --git a/permix/src/standard-schema/libraries.test.ts b/permix/src/standard-schema/libraries.test.ts new file mode 100644 index 00000000..fa521445 --- /dev/null +++ b/permix/src/standard-schema/libraries.test.ts @@ -0,0 +1,354 @@ +import { type } from 'arktype' +import { Schema } from 'effect' +import * as v from 'valibot' +import { describe, expect, expectTypeOf, it } from 'vitest' +import { z } from 'zod' + +import { action, createPermix as createPermixCore } from '../core' +import type { StandardSchemaV1 } from '../core/standard-schema' +import { PermixAsyncValidationError, PermixValidationError } from './errors' +import { createPermix, entity } from './permix' + +const validPost = { id: 'p1', authorId: '1' } +const otherAuthor = { id: 'p1', authorId: '2' } +const missingId = { authorId: '1' } + +const zodPost = z.object({ + id: z.string(), + authorId: z.string(), +}) + +const valibotPost = v.object({ + id: v.string(), + authorId: v.string(), +}) + +const arktypePost = type({ + id: 'string', + authorId: 'string', +}) + +const effectPost = Schema.standardSchemaV1( + Schema.Struct({ + id: Schema.String, + authorId: Schema.String, + }) +) + +function isAuthor(post: unknown) { + return (post as { authorId?: string } | undefined)?.authorId === '1' +} + +const libraries: { name: string; schema: StandardSchemaV1 }[] = [ + { name: 'zod', schema: zodPost }, + { name: 'valibot', schema: valibotPost }, + { name: 'arktype', schema: arktypePost }, + { name: 'effect', schema: effectPost }, +] + +describe.each(libraries)('$name Standard Schema', ({ schema }) => { + it('implements Standard Schema v1', () => { + expect(schema['~standard'].version).toBe(1) + expect(schema['~standard'].validate).toBeTypeOf('function') + }) + + it('checks valid and denied entity data via action() on core createPermix', () => { + const definition = { + post: [action('update', schema)], + } as const + + const permix = createPermixCore().setup({ + post: { + update: isAuthor, + }, + }) + + expect(permix.check('post.update', validPost)).toBe(true) + expect(permix.check('post.update', otherAuthor)).toBe(false) + }) + + it('creates a factory instance and checks valid and denied entity data', () => { + const permix = createPermix({ post: schema }).setup({ + post: { + create: true, + read: true, + update: isAuthor, + delete: false, + }, + }) + + expect(permix.check('post.update', validPost)).toBe(true) + expect(permix.check('post.update', otherAuthor)).toBe(false) + }) + + it('does not parse check data unless validate is set', () => { + const permix = createPermix({ post: schema }).setup({ + post: { + create: false, + read: false, + update: isAuthor, + delete: false, + }, + }) + + expect(permix.check('post.update', missingId as never)).toBe(true) + }) + + it('validate: deny returns false for invalid data', () => { + const permix = createPermix({ post: schema }, { validate: 'deny' }).setup({ + post: { + create: false, + read: false, + update: isAuthor, + delete: false, + }, + }) + + expect(permix.check('post.update', validPost)).toBe(true) + expect(permix.check('post.update', missingId as never)).toBe(false) + }) + + it('validate: throw raises PermixValidationError for invalid data', () => { + const permix = createPermix({ post: schema }, { validate: 'throw' }).setup({ + post: { + create: false, + read: false, + update: isAuthor, + delete: false, + }, + }) + + expect(permix.check('post.update', validPost)).toBe(true) + expect(() => permix.check('post.update', missingId as never)).toThrow( + PermixValidationError + ) + }) +}) + +describe('library type inference', () => { + it('zod infers entity data from the schema output', () => { + createPermix({ post: zodPost }).setup({ + post: { + create: false, + read: false, + update: (post) => { + expectTypeOf(post).toEqualTypeOf< + { id: string; authorId: string } | undefined + >() + return true + }, + delete: false, + }, + }) + }) + + it('valibot infers entity data from the schema output', () => { + createPermix({ post: valibotPost }).setup({ + post: { + create: false, + read: false, + update: (post) => { + expectTypeOf(post).toEqualTypeOf< + { id: string; authorId: string } | undefined + >() + return true + }, + delete: false, + }, + }) + }) + + it('arktype infers entity data from the schema output', () => { + createPermix({ post: arktypePost }).setup({ + post: { + create: false, + read: false, + update: (post) => { + expectTypeOf(post).toEqualTypeOf< + { id: string; authorId: string } | undefined + >() + return true + }, + delete: false, + }, + }) + }) + + it('effect infers entity data from the schema output', () => { + createPermix({ post: effectPost }).setup({ + post: { + create: false, + read: false, + update: (post) => { + expectTypeOf(post).toEqualTypeOf< + { readonly id: string; readonly authorId: string } | undefined + >() + return true + }, + delete: false, + }, + }) + }) +}) + +describe('factory validate option', () => { + it('defaults to off', () => { + const permix = createPermix({ post: zodPost }) + expect(permix.validate).toBe(false) + }) + + it('attaches path and issues on PermixValidationError', () => { + const schema: StandardSchemaV1 = { + '~standard': { + version: 1, + vendor: 'test', + validate: () => ({ issues: [{ message: 'id required' }] }), + }, + } + + const permix = createPermix({ post: schema }, { validate: 'throw' }).setup({ + post: { + create: false, + read: false, + update: () => true, + delete: false, + }, + }) + + expect(() => permix.check('post.update', validPost)).toThrow( + expect.objectContaining({ + name: 'PermixValidationError', + path: 'post.update', + issues: [{ message: 'id required' }], + }) + ) + }) + + it('passes transformed output to the rule when validate is on', () => { + const schema = z + .object({ + id: z.string(), + authorId: z.string(), + }) + .transform((post) => ({ + ...post, + authorId: post.authorId.toUpperCase(), + })) + + const permix = createPermix({ post: schema }, { validate: 'deny' }).setup({ + post: { + create: false, + read: false, + update: (post) => post?.authorId === 'ABC', + delete: false, + }, + }) + + expect(permix.check('post.update', { id: 'p1', authorId: 'abc' })).toBe( + true + ) + }) + + it('validates inside callback checks', () => { + const permix = createPermix({ post: zodPost }, { validate: 'deny' }).setup({ + post: { + create: false, + read: false, + update: (post) => post?.authorId === '1', + delete: false, + }, + }) + + expect( + permix.check( + (c) => + c('post.update', missingId as typeof validPost) || + c('post.update', validPost) + ) + ).toBe(true) + + expect( + permix.check((c) => c('post.update', missingId as typeof validPost)) + ).toBe(false) + }) + + it('skips validation for ~any / ~all and checks without data', () => { + const permix = createPermix({ post: zodPost }, { validate: 'throw' }).setup( + { + post: { + create: true, + read: false, + update: (post) => post?.authorId === '1', + delete: false, + }, + } + ) + + expect(permix.check('post.create')).toBe(true) + expect(permix.check('post.update')).toBe(false) + expect(permix.check('post.~any')).toBe(true) + expect(permix.check('~all')).toBe(false) + }) + + it('skips validation for untyped action tuples', () => { + const permix = createPermix( + { + post: zodPost, + dashboard: ['view'] as const, + }, + { validate: 'throw' } + ).setup({ + post: { + create: false, + read: false, + update: () => true, + delete: false, + }, + dashboard: { view: true }, + }) + + expect(permix.check('dashboard.view')).toBe(true) + }) + + it('validates entity() schemas', () => { + const permix = createPermix( + { + post: entity(zodPost, ['edit'] as const), + }, + { validate: 'deny' } + ).setup({ + post: { + edit: (post) => post?.authorId === '1', + }, + }) + + expect(permix.check('post.edit', validPost)).toBe(true) + expect(permix.check('post.edit', missingId as typeof validPost)).toBe(false) + }) + + it('throws PermixAsyncValidationError when validate returns a Promise', () => { + const asyncSchema: StandardSchemaV1 = { + '~standard': { + version: 1, + vendor: 'test', + validate: () => Promise.resolve({ value: validPost }), + }, + } + + const permix = createPermix( + { post: asyncSchema }, + { validate: 'deny' } + ).setup({ + post: { + create: false, + read: false, + update: () => true, + delete: false, + }, + }) + + expect(() => permix.check('post.update', validPost)).toThrow( + PermixAsyncValidationError + ) + }) +}) diff --git a/permix/src/standard-schema/permix.test.ts b/permix/src/standard-schema/permix.test.ts new file mode 100644 index 00000000..338faff9 --- /dev/null +++ b/permix/src/standard-schema/permix.test.ts @@ -0,0 +1,205 @@ +import * as v from 'valibot' +import { describe, expect, expectTypeOf, it, vi } from 'vitest' +import { z } from 'zod' + +import { PermixRuleNotDefinedError } from '../core/errors' +import { + PermixInvalidActionsError, + PermixInvalidSchemaMapError, +} from './errors' +import { createPermix, entity } from './permix' + +const postSchema = z.object({ + id: z.string(), + authorId: z.string(), +}) + +const commentSchema = z.object({ + id: z.string(), + postId: z.string(), +}) + +const valibotPostSchema = v.object({ + id: v.string(), + authorId: v.string(), +}) + +describe('standard-schema createPermix', () => { + it('creates CRUD entities from a Zod schema map', () => { + const permix = createPermix({ + post: postSchema, + comment: commentSchema, + }) + + expect(permix.entities).toStrictEqual(['post', 'comment']) + expect(permix.actions).toStrictEqual(['create', 'read', 'update', 'delete']) + + const ready = permix.setup({ + post: { + create: true, + read: true, + update: (post) => { + expectTypeOf(post).toEqualTypeOf< + { id: string; authorId: string } | undefined + >() + return post?.authorId === '1' + }, + delete: false, + }, + comment: { + create: true, + read: true, + update: false, + delete: false, + }, + }) + + expect(ready.check('post.create')).toBe(true) + expect(ready.check('post.delete')).toBe(false) + expect(ready.check('post.update', { id: 'p1', authorId: '1' })).toBe(true) + expect(ready.check('post.update', { id: 'p1', authorId: '2' })).toBe(false) + expect(ready.check('comment.read')).toBe(true) + }) + + it('supports a custom action set via the actions option', () => { + const permix = createPermix( + { post: postSchema }, + { actions: ['view', 'edit'] } + ) + + expect(permix.actions).toStrictEqual(['view', 'edit']) + + const ready = permix.setup({ + post: { view: true, edit: false }, + }) + + expect(ready.check('post.view')).toBe(true) + expect(ready.check('post.edit')).toBe(false) + // @ts-expect-error 'create' is not in the custom action set + expect(() => ready.check('post.create')).toThrow(PermixRuleNotDefinedError) + }) + + it('lets entity() customise actions and require data per action', () => { + const permix = createPermix({ + post: entity(postSchema, [ + 'create', + 'read', + { name: 'publish', required: true }, + ]), + dashboard: ['view'] as const, + }) + + const ready = permix.setup({ + post: { + create: true, + read: true, + publish: (post) => post.authorId === '1', + }, + dashboard: { view: true }, + }) + + expect(ready.check('post.create')).toBe(true) + expect(ready.check('dashboard.view')).toBe(true) + expect(ready.check('post.publish', { id: 'p1', authorId: '1' })).toBe(true) + // @ts-expect-error data is required + expect(() => ready.check('post.publish')).toThrow() + // @ts-expect-error untyped action list has no entity data + expect(ready.check('dashboard.view', { id: 'x' })).toBe(true) + }) + + it('accepts Valibot schemas in the map', () => { + const permix = createPermix({ post: valibotPostSchema }) + + const ready = permix.setup({ + post: { + create: true, + read: true, + update: (post) => post?.authorId === '1', + delete: false, + }, + }) + + expect(ready.check('post.update', { id: 'p1', authorId: '1' })).toBe(true) + }) + + it('throws when actions is an empty array', () => { + expect(() => createPermix({ post: postSchema }, { actions: [] })).toThrow( + PermixInvalidActionsError + ) + }) + + it('throws when entity() is given an empty action list', () => { + expect(() => entity(postSchema, [])).toThrow(PermixInvalidActionsError) + }) + + it('throws when a map value is not a schema, entity(), or action tuple', () => { + expect(() => + createPermix({ + post: postSchema, + bad: { hello: true }, + } as never) + ).toThrow(PermixInvalidSchemaMapError) + }) + + it('exposes entity and action types', () => { + const permix = createPermix({ post: postSchema, comment: commentSchema }) + + expectTypeOf(permix.entities).toEqualTypeOf<('post' | 'comment')[]>() + expectTypeOf(permix.actions).toEqualTypeOf< + readonly ['create', 'read', 'update', 'delete'] + >() + expectTypeOf(permix.validate).toEqualTypeOf() + expect(permix.validate).toBe(false) + }) + + it('records the validate option on the instance', () => { + const permix = createPermix({ post: postSchema }, { validate: 'deny' }) + expect(permix.validate).toBe('deny') + }) + + it('fires the check hook with allowed false when validate deny rejects data', () => { + const permix = createPermix( + { post: postSchema }, + { validate: 'deny' } + ).setup({ + post: { + create: true, + read: true, + update: true, + delete: false, + }, + }) + + const onCheck = vi.fn() + permix.hook('check', onCheck) + + expect(permix.check('post.update', { id: 1 } as never)).toBe(false) + expect(onCheck).toHaveBeenCalledOnce() + expect(onCheck).toHaveBeenCalledWith({ + path: 'post.update', + data: { id: 1 }, + allowed: false, + reasons: [], + }) + }) + + it('denies explain() when validate deny rejects data', () => { + const permix = createPermix( + { post: postSchema }, + { validate: 'deny' } + ).setup({ + post: { + create: true, + read: true, + update: true, + delete: false, + }, + }) + + expect(permix.explain('post.update', { id: 1 } as never)).toStrictEqual({ + allowed: false, + path: 'post.update', + reasons: [], + }) + }) +}) diff --git a/permix/src/standard-schema/permix.ts b/permix/src/standard-schema/permix.ts new file mode 100644 index 00000000..4c7929ad --- /dev/null +++ b/permix/src/standard-schema/permix.ts @@ -0,0 +1,348 @@ +import type { Permix as PermixCore } from '../core' +import { createPermix as createPermixCore, notifyCheck } from '../core' +import type { Action } from '../core/definitions' +import type { + InferStandardSchemaOutput, + StandardSchemaV1, +} from '../core/standard-schema' +import { + PermixInvalidActionsError, + PermixInvalidSchemaMapError, +} from './errors' +import type { ValidateMode } from './validate' +import { checkWithValidation, explainWithValidation } from './validate' + +export type { ValidateMode } from './validate' + +/** + * The default CRUD action set used when no `actions` are provided. + */ +export const DEFAULT_STANDARD_SCHEMA_ACTIONS = [ + 'create', + 'read', + 'update', + 'delete', +] as const + +export type DefaultStandardSchemaAction = + (typeof DEFAULT_STANDARD_SCHEMA_ACTIONS)[number] + +/** + * An action name, or a named spec that marks entity data as required. + * The entity type is taken from the schema passed to {@link entity} or the + * map value — do not repeat `type` / `schema` here. + */ +export type EntityAction = string | { name: string; required?: boolean } + +export interface EntityConfig< + S extends StandardSchemaV1 = StandardSchemaV1, + Actions extends readonly EntityAction[] = readonly EntityAction[], +> { + readonly schema: S + readonly actions: Actions +} + +export type SchemaMapValue = StandardSchemaV1 | EntityConfig | readonly Action[] + +export interface SchemaMap { + readonly [key: string]: SchemaMapValue +} + +type NamedAction< + S extends StandardSchemaV1, + A extends EntityAction, +> = A extends { name: infer N extends string; required: true } + ? { name: N; type: InferStandardSchemaOutput; required: true } + : A extends string + ? { name: A; type: InferStandardSchemaOutput } + : A extends { name: infer N extends string } + ? { name: N; type: InferStandardSchemaOutput } + : never + +type SpecsFromActionNames< + S extends StandardSchemaV1, + Actions extends readonly string[], +> = { + [I in keyof Actions]: Actions[I] extends infer N extends string + ? { name: N; type: InferStandardSchemaOutput } + : never +} + +type SpecsFromEntityActions< + S extends StandardSchemaV1, + Actions extends readonly EntityAction[], +> = { [I in keyof Actions]: NamedAction } + +/** + * Permix {@link import('../core/definitions').Definition} derived from a + * Standard Schema map. Bare schemas receive `Actions`; {@link entity} entries + * keep their own action list; plain action tuples are passed through untyped. + */ +export type StandardSchemaDefinition< + M extends { [K in keyof M]: SchemaMapValue }, + Actions extends readonly string[] = typeof DEFAULT_STANDARD_SCHEMA_ACTIONS, +> = { + [K in keyof M]: M[K] extends StandardSchemaV1 + ? SpecsFromActionNames + : M[K] extends EntityConfig + ? SpecsFromEntityActions + : M[K] extends readonly Action[] + ? M[K] + : never +} + +export interface CreateStandardSchemaPermixOptions< + Actions extends readonly string[], +> { + /** + * Override the actions generated for every bare schema. Pass an `as const` + * tuple to preserve literal types. Ignored for {@link entity} entries and + * plain action tuples. + * + * @default ['create', 'read', 'update', 'delete'] + */ + actions?: Actions + + /** + * When set, `check()` and `explain()` run the entity's Standard Schema + * `validate` on the data argument before the rule. Parsed output (including + * transforms) is what the rule receives. + * + * - `'deny'` — invalid data returns `false` + * - `'throw'` — invalid data throws {@link import('./errors').PermixValidationError} + * + * Omitted or `false`: no runtime validation (default). Checks without data, + * `~any` / `~all`, and untyped action tuples skip validation. Async schemas + * throw {@link import('./errors').PermixAsyncValidationError}. + * + * @default false + */ + validate?: false | ValidateMode +} + +/** + * Extends the core Permix API with Standard Schema metadata. + */ +export interface StandardSchemaPermix< + M extends { [K in keyof M]: SchemaMapValue }, + Actions extends readonly string[], +> extends PermixCore> { + /** + * The default action names applied to every bare schema in the map. + */ + readonly actions: Actions + + /** + * The entity keys from the supplied schema map. + */ + readonly entities: (keyof M & string)[] + + /** + * Runtime validation mode passed to {@link createPermix}. `false` when + * omitted. + */ + readonly validate: false | ValidateMode +} + +function isStandardSchema(value: unknown): value is StandardSchemaV1 { + // ArkType and Effect Schema attach `~standard` to a callable Type + // (`typeof` is `'function'`), not a plain object. + if ( + value === null || + (typeof value !== 'object' && typeof value !== 'function') + ) { + return false + } + const standard = (value as { '~standard'?: unknown })['~standard'] + if (typeof standard !== 'object' || standard === null) { + return false + } + const props = standard as { version?: unknown; validate?: unknown } + return props.version === 1 && typeof props.validate === 'function' +} + +function isEntityConfig(value: unknown): value is EntityConfig { + return ( + typeof value === 'object' && + value !== null && + 'schema' in value && + 'actions' in value && + isStandardSchema((value as EntityConfig).schema) && + Array.isArray((value as EntityConfig).actions) + ) +} + +function isActionList(value: unknown): value is readonly Action[] { + return ( + Array.isArray(value) && + value.length > 0 && + value.every((item) => { + if (typeof item === 'string') { + return true + } + return ( + typeof item === 'object' && + item !== null && + typeof (item as { name?: unknown }).name === 'string' + ) + }) + ) +} + +function assertActions(actions: readonly string[]) { + if (!Array.isArray(actions) || actions.length === 0) { + throw new PermixInvalidActionsError() + } +} + +/** + * Attach a Standard Schema to a custom action list for one entity. + * + * @example + * ```ts + * createPermix({ + * post: entity(postSchema, ['create', 'read', { name: 'publish', required: true }]), + * dashboard: ['view'], + * }) + * ``` + */ +export function entity< + S extends StandardSchemaV1, + const Actions extends readonly EntityAction[], +>(schema: S, actions: Actions): EntityConfig { + assertActions( + actions.map((item) => (typeof item === 'string' ? item : item.name)) + ) + return { schema, actions } +} + +/** + * Create a type-safe Permix instance whose permission tree mirrors a map of + * Standard Schema validators (Zod, Valibot, ArkType, Effect Schema, …). + * + * Every bare schema becomes a top-level entity with the same set of actions + * (CRUD by default). Use {@link entity} to customise actions per entity, or + * pass an action-name tuple for an untyped entity. + * + * Schemas infer entity types. Pass `{ validate: 'deny' | 'throw' }` to also + * parse `check()` data at runtime; the default is off. The map type is + * self-indexed so callable schemas (ArkType, Effect) keep their output types. + * + * @example + * ```ts + * import { z } from 'zod' + * import { createPermix } from 'permix/standard-schema' + * + * const postSchema = z.object({ + * id: z.string(), + * authorId: z.string(), + * }) + * + * const permix = createPermix({ post: postSchema }) + * + * permix.setup({ + * post: { + * create: true, + * read: true, + * update: (post) => post.authorId === me.id, + * delete: false, + * }, + * }) + * + * permix.check('post.update', somePost) + * ``` + */ +export function createPermix< + const M extends { [K in keyof M]: SchemaMapValue }, + const Actions extends readonly string[] = + typeof DEFAULT_STANDARD_SCHEMA_ACTIONS, +>( + map: M, + options: CreateStandardSchemaPermixOptions = {} +): StandardSchemaPermix { + const actions = (options.actions ?? + DEFAULT_STANDARD_SCHEMA_ACTIONS) as unknown as Actions + const validate = options.validate ?? false + + assertActions(actions) + + const entities = Object.keys(map) as (keyof M & string)[] + const schemasByEntity = new Map() + + for (const key of entities) { + const value = map[key] + if (isStandardSchema(value)) { + schemasByEntity.set(key, value) + continue + } + if (isEntityConfig(value)) { + assertActions( + value.actions.map((item) => + typeof item === 'string' ? item : item.name + ) + ) + schemasByEntity.set(key, value.schema) + continue + } + if (isActionList(value)) { + continue + } + throw new PermixInvalidSchemaMapError(key) + } + + type D = StandardSchemaDefinition + + function decorate(instance: PermixCore): StandardSchemaPermix { + const originalSetup = instance.setup.bind(instance) + const originalHydrate = instance.hydrate.bind(instance) + const originalInstall = instance.install.bind(instance) + const originalCheck = instance.check.bind(instance) + const originalExplain = instance.explain.bind(instance) + + const decorated: StandardSchemaPermix = { + ...instance, + actions, + entities, + validate, + setup(rules) { + return decorate(originalSetup(rules)) + }, + hydrate(state) { + return decorate(originalHydrate(state)) + }, + install(options) { + return decorate(originalInstall(options)) + }, + check: validate + ? (...args: Parameters) => + checkWithValidation( + originalCheck, + schemasByEntity, + validate, + args, + () => { + notifyCheck(instance, args, false) + } + ) + : originalCheck, + explain: validate + ? (...args: Parameters) => + explainWithValidation( + originalExplain, + schemasByEntity, + validate, + args + ) + : originalExplain, + } + return decorated + } + + return decorate(createPermixCore()) +} + +/** Return type of {@link createPermix}. */ +export type StandardSchemaPermixInstance< + M extends { [K in keyof M]: SchemaMapValue }, + Actions extends readonly string[] = typeof DEFAULT_STANDARD_SCHEMA_ACTIONS, +> = ReturnType> diff --git a/permix/src/standard-schema/validate.ts b/permix/src/standard-schema/validate.ts new file mode 100644 index 00000000..778508bb --- /dev/null +++ b/permix/src/standard-schema/validate.ts @@ -0,0 +1,129 @@ +import type { CheckArgs, CheckerFn, ExplainResult } from '../core' +import type { Definition } from '../core/definitions' +import type { StandardSchemaV1 } from '../core/standard-schema' +import { PermixAsyncValidationError, PermixValidationError } from './errors' + +export type ValidateMode = 'deny' | 'throw' + +const DENY = Symbol('deny') +const SKIP = Symbol('skip') + +function isSpecialPath(path: string): boolean { + const last = path.split('.').pop() + return last === '~any' || last === '~all' +} + +function entityKey(path: string): string { + const dot = path.indexOf('.') + return dot === -1 ? path : path.slice(0, dot) +} + +/** + * Run a Standard Schema `validate` against `check()` data. + * + * @returns `DENY` when invalid and mode is `'deny'`, `SKIP` when there is + * nothing to validate, or the parsed output value. + */ +export function prepareCheckData( + schemas: Map, + mode: ValidateMode, + path: string, + data: unknown +): typeof DENY | typeof SKIP | unknown { + if (isSpecialPath(path) || data === undefined) { + return SKIP + } + + const schema = schemas.get(entityKey(path)) + if (!schema) { + return SKIP + } + + const result = schema['~standard'].validate(data) + if (result instanceof Promise) { + throw new PermixAsyncValidationError(path) + } + + if (result.issues) { + if (mode === 'throw') { + throw new PermixValidationError(path, result.issues) + } + return DENY + } + + return result.value +} + +export function checkWithValidation( + check: (...args: CheckArgs) => boolean, + schemas: Map, + mode: ValidateMode, + args: CheckArgs, + onDeny?: () => void +): boolean { + const first = args[0] + + if (typeof first === 'function') { + return check((c: CheckerFn) => + first((path, ...data) => { + const prepared = prepareCheckData(schemas, mode, path, data[0]) + if (prepared === DENY) { + return false + } + if (prepared === SKIP) { + return c(path, ...data) + } + return (c as (nextPath: string, nextData?: unknown) => boolean)( + path, + prepared + ) + }) + ) + } + + const prepared = prepareCheckData(schemas, mode, first, args[1]) + if (prepared === DENY) { + onDeny?.() + return false + } + if (prepared === SKIP) { + return check(...args) + } + return check(...([first, prepared] as unknown as CheckArgs)) +} + +export function explainWithValidation( + explain: (...args: CheckArgs) => ExplainResult, + schemas: Map, + mode: ValidateMode, + args: CheckArgs +): ExplainResult { + const first = args[0] + + if (typeof first === 'function') { + return explain((c: CheckerFn) => + first((path, ...data) => { + const prepared = prepareCheckData(schemas, mode, path, data[0]) + if (prepared === DENY) { + return false + } + if (prepared === SKIP) { + return c(path, ...data) + } + return (c as (nextPath: string, nextData?: unknown) => boolean)( + path, + prepared + ) + }) + ) + } + + const prepared = prepareCheckData(schemas, mode, first, args[1]) + if (prepared === DENY) { + return { allowed: false, path: first, reasons: [] } + } + if (prepared === SKIP) { + return explain(...args) + } + return explain(...([first, prepared] as unknown as CheckArgs)) +} diff --git a/permix/src/svelte/Check.svelte b/permix/src/svelte/Check.svelte index b2e9f0ab..b62b72ba 100644 --- a/permix/src/svelte/Check.svelte +++ b/permix/src/svelte/Check.svelte @@ -1,8 +1,8 @@ {@render children()} diff --git a/permix/src/svelte/PermixProvider.svelte b/permix/src/svelte/PermixProvider.svelte index 586c1d12..6f6458c9 100644 --- a/permix/src/svelte/PermixProvider.svelte +++ b/permix/src/svelte/PermixProvider.svelte @@ -1,12 +1,16 @@ {@render children()} diff --git a/permix/src/svelte/__fixtures__/HydrateConsumer.svelte b/permix/src/svelte/__fixtures__/HydrateConsumer.svelte index d00a05bb..90e92694 100644 --- a/permix/src/svelte/__fixtures__/HydrateConsumer.svelte +++ b/permix/src/svelte/__fixtures__/HydrateConsumer.svelte @@ -9,3 +9,4 @@ const permissions = usePermix(permix)
{permissions.check('post.create').toString()}
+
{permissions.isReady.toString()}
diff --git a/permix/src/svelte/components.test.ts b/permix/src/svelte/components.test.ts index a999f398..406793bc 100644 --- a/permix/src/svelte/components.test.ts +++ b/permix/src/svelte/components.test.ts @@ -13,9 +13,7 @@ describe('components', () => { it('should check hydration', async () => { const permixServer = createPermix<{ post: ['create', 'read'] - }>() - - permixServer.setup({ + }>().setup({ post: { create: true, read: false, @@ -35,12 +33,40 @@ describe('components', () => { expect(getByTestId('create')).toHaveTextContent('true') }) + it('uses dehydrated rules on the first render and rehydrates when state changes', async () => { + const permixServer = createPermix<{ + post: ['create', 'read'] + }>().setup({ + post: { + create: true, + read: false, + }, + }) + + const dehydrated = permixServer.dehydrate() + const permixClient = createPermix<{ + post: ['create', 'read'] + }>() + + const { getByTestId, rerender } = render(HydrateApp, { + props: { permix: permixClient, state: dehydrated }, + }) + + expect(getByTestId('create')).toHaveTextContent('true') + expect(getByTestId('ready')).toHaveTextContent('false') + + await rerender({ + permix: permixClient, + state: { post: { create: false, read: false } }, + }) + + expect(getByTestId('create')).toHaveTextContent('false') + }) + it('should work with Check component', () => { const permix = createPermix<{ post: ['create'] - }>() - - permix.setup({ + }>().setup({ post: { create: true, }, @@ -54,9 +80,7 @@ describe('components', () => { it('should work with Check component and data', () => { const permix = createPermix<{ post: [{ name: 'edit'; type: { authorId: string } }] - }>() - - permix.setup({ + }>().setup({ post: { edit: (post) => post?.authorId === '1', }, @@ -79,9 +103,7 @@ describe('components', () => { it('should work with Check component and DOM rerender', async () => { const permix = createPermix<{ post: ['read'] - }>() - - permix.setup({ + }>().setup({ post: { read: false, }, @@ -105,9 +127,7 @@ describe('components', () => { it('should work with reverse prop', async () => { const permix = createPermix<{ post: ['create'] - }>() - - permix.setup({ + }>().setup({ post: { create: true, }, diff --git a/permix/src/svelte/components.ts b/permix/src/svelte/components.ts index 7b3f365d..12fa6ca0 100644 --- a/permix/src/svelte/components.ts +++ b/permix/src/svelte/components.ts @@ -25,6 +25,6 @@ export function createComponents( permix: Pick, 'getRules' | 'check'> ): PermixComponents { return { - Check, + Check: Check as Component>>, } } diff --git a/permix/src/svelte/context.svelte.ts b/permix/src/svelte/context.svelte.ts index 14ab311f..75989b0d 100644 --- a/permix/src/svelte/context.svelte.ts +++ b/permix/src/svelte/context.svelte.ts @@ -1,7 +1,7 @@ -import { getContext, setContext } from 'svelte' +import { getContext, onDestroy, setContext } from 'svelte' import type { Definition, Permix, Rules } from '../core' -import { createCheck } from '../core' +import { runCheck, runExplain } from '../core' export interface PermixContext { permix: Permix @@ -9,7 +9,11 @@ export interface PermixContext { rules: Rules | null } -const PERMIX_CONTEXT_KEY = Symbol('svelte-permix') +export const PERMIX_CONTEXT_KEY = Symbol('svelte-permix') + +export function createPermixContextKey() { + return Symbol('svelte-permix') +} /** * Provides Permix context to the Svelte component tree. @@ -18,32 +22,39 @@ const PERMIX_CONTEXT_KEY = Symbol('svelte-permix') * * @link https://permix.letstri.dev/docs/integrations/svelte */ -export function providePermix(permix: Permix): void { +export function providePermix( + permix: Permix, + key: symbol = PERMIX_CONTEXT_KEY +): void { const context = $state>({ permix, isReady: permix.isReady(), rules: permix.getRules(), }) - setContext(PERMIX_CONTEXT_KEY, context) + setContext(key, context) - $effect(() => { - const setup = permix.hook('setup', () => { - context.rules = permix.getRules() - }) - const ready = permix.hook('ready', () => { - context.isReady = permix.isReady() - }) + const setup = permix.hook('setup', (instance) => { + context.permix = instance + context.rules = instance.getRules() + context.isReady = instance.isReady() + }) + const ready = permix.hook('ready', (instance) => { + context.permix = instance + context.rules = instance.getRules() + context.isReady = instance.isReady() + }) - return () => { - setup() - ready() - } + onDestroy(() => { + setup() + ready() }) } -export function usePermixContext(): PermixContext { - const context = getContext | undefined>(PERMIX_CONTEXT_KEY) +export function usePermixContext( + key: symbol = PERMIX_CONTEXT_KEY +): PermixContext { + const context = getContext | undefined>(key) if (!context) { throw new Error( @@ -60,15 +71,20 @@ export function usePermixContext(): PermixContext { * @link https://permix.letstri.dev/docs/integrations/svelte */ export function usePermix( - permix: Pick, 'getRules' | 'check'> + _permix?: Pick, 'getRules' | 'check'>, + key: symbol = PERMIX_CONTEXT_KEY ) { - const context = usePermixContext() + const context = usePermixContext(key) const check: Permix['check'] = (...args) => - createCheck(() => context.rules ?? permix.getRules())(...args) + runCheck(context.permix, context.rules, ...args) + + const explain: Permix['explain'] = (...args) => + runExplain(context.permix, context.rules, ...args) return { check, + explain, get isReady() { return context.isReady }, diff --git a/permix/src/svelte/create-permix.ts b/permix/src/svelte/create-permix.ts new file mode 100644 index 00000000..e4805857 --- /dev/null +++ b/permix/src/svelte/create-permix.ts @@ -0,0 +1,105 @@ +import type { Component, Snippet } from 'svelte' + +import type { Definition, DehydratedState, Permix } from '../core' +import { createPermix as createPermixCore } from '../core' +import Check from './Check.svelte' +import type { CheckProps, PermixComponents } from './components' +import { + createPermixContextKey, + usePermix as usePermixFromContext, +} from './context.svelte' +import PermixHydrate from './PermixHydrate.svelte' +import PermixProvider from './PermixProvider.svelte' + +export interface CreatePermixResult { + permix: Permix + PermixProvider: Component<{ children: Snippet }> + PermixHydrate: Component<{ children: Snippet; state: DehydratedState }> + usePermix: () => { + check: Permix['check'] + explain: Permix['explain'] + readonly isReady: boolean + } + Check: PermixComponents['Check'] +} + +/** + * Create a Permix instance with isolated Svelte bindings. + * + * Call once at module scope. The bound provider does not take a `permix` + * prop. `usePermix()` takes no instance argument. + * + * @link https://permix.letstri.dev/docs/integrations/svelte + */ +export function createPermix( + instance?: Permix +): CreatePermixResult { + const permix = instance ?? createPermixCore() + const key = createPermixContextKey() + + const BoundProvider = (( + $$anchor: unknown, + $$props: { children: Snippet } + ) => { + PermixProvider($$anchor as never, { + get permix() { + return permix + }, + get children() { + return $$props.children + }, + contextKey: key, + }) + }) as Component<{ children: Snippet }> + + const BoundHydrate = (( + $$anchor: unknown, + $$props: { children: Snippet; state: DehydratedState } + ) => { + PermixHydrate($$anchor as never, { + get state() { + return $$props.state + }, + get children() { + return $$props.children + }, + contextKey: key, + }) + }) as Component<{ children: Snippet; state: DehydratedState }> + + const BoundCheck = (($$anchor: unknown, $$props: CheckProps) => { + Check( + $$anchor as never, + { + get path() { + return $$props.path + }, + get data() { + return $$props.data + }, + get reverse() { + return $$props.reverse ?? false + }, + get children() { + return $$props.children + }, + get otherwise() { + return $$props.otherwise + }, + contextKey: key, + } as never + ) + }) as PermixComponents['Check'] + + function useBoundPermix() { + return usePermixFromContext(permix, key) + } + + return { + permix, + PermixProvider: BoundProvider, + PermixHydrate: BoundHydrate, + usePermix: useBoundPermix, + Check: BoundCheck, + } +} diff --git a/permix/src/svelte/hooks.test.ts b/permix/src/svelte/hooks.test.ts index 8e37ca67..9b3eeef8 100644 --- a/permix/src/svelte/hooks.test.ts +++ b/permix/src/svelte/hooks.test.ts @@ -10,9 +10,7 @@ describe('permix svelte', () => { it('should work with custom hook', () => { const permix = createPermix<{ post: [{ name: 'create'; type: { id: string } }, 'read'] - }>() - - permix.setup({ + }>().setup({ post: { create: (post) => post?.id === '1', read: false, @@ -25,12 +23,26 @@ describe('permix svelte', () => { expect(getByTestId('read')).toHaveTextContent('false') }) - it('should work with DOM rerender', async () => { + it('reads ready state on the first render when setup ran before subscribe', () => { const permix = createPermix<{ post: [{ name: 'create'; type: { id: string } }, 'read'] - }>() + }>().setup({ + post: { + create: () => true, + read: true, + }, + }) - permix.setup({ + const { getByTestId } = render(HookApp, { props: { permix } }) + + expect(getByTestId('ready')).toHaveTextContent('true') + expect(getByTestId('read')).toHaveTextContent('true') + }) + + it('should work with DOM rerender', async () => { + const permix = createPermix<{ + post: [{ name: 'create'; type: { id: string } }, 'read'] + }>().setup({ post: { create: (post) => post?.id === '1', read: false, diff --git a/permix/src/svelte/index.ts b/permix/src/svelte/index.ts index 8ed2d4ef..f39b5e2a 100644 --- a/permix/src/svelte/index.ts +++ b/permix/src/svelte/index.ts @@ -1,7 +1,4 @@ -export { default as Check } from './Check.svelte' -export { createComponents } from './components' +export { createPermix } from './create-permix' +export type { CreatePermixResult } from './create-permix' export type { CheckProps, PermixComponents } from './components' -export { providePermix, usePermix } from './context.svelte' export type { PermixContext } from './context.svelte' -export { default as PermixHydrate } from './PermixHydrate.svelte' -export { default as PermixProvider } from './PermixProvider.svelte' diff --git a/permix/src/tanstack-start/hydration.test.tsx b/permix/src/tanstack-start/hydration.test.tsx index 40dbd092..9b657eb7 100644 --- a/permix/src/tanstack-start/hydration.test.tsx +++ b/permix/src/tanstack-start/hydration.test.tsx @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest' import type { ValidateDefinition } from '../core' import { createPermix as createCorePermix } from '../core' -import { PermixHydrate, PermixProvider, usePermix } from '../react' +import { createPermix as createReactPermix } from '../react' import { createPermix as createStartPermix } from './permix' import '@testing-library/jest-dom/vitest' @@ -38,8 +38,11 @@ describe('tanstack-start → react hydration round-trip', () => { // Client: separate singleton + hydrate via the react integration. const permixClient = createCorePermix() + const { PermixProvider, PermixHydrate, usePermix } = + createReactPermix(permixClient) + function PostStatus() { - const { check } = usePermix(permixClient) + const { check } = usePermix() return (
{String(check('post.create'))} @@ -49,7 +52,7 @@ describe('tanstack-start → react hydration round-trip', () => { } const { getByTestId } = render( - + diff --git a/permix/src/tanstack-start/permix.test.ts b/permix/src/tanstack-start/permix.test.ts index b01cf762..f409ec8c 100644 --- a/permix/src/tanstack-start/permix.test.ts +++ b/permix/src/tanstack-start/permix.test.ts @@ -214,7 +214,8 @@ describe('tanstack-start createPermix', () => { expect(onCheck).toHaveBeenCalledWith({ path: 'post.create', - data: undefined, + allowed: true, + reasons: [], }) }) }) @@ -302,7 +303,8 @@ describe('tanstack-start createPermix', () => { expect(onForbidden).toHaveBeenCalledWith({ next: run.next, path: 'post.create', - data: undefined, + allowed: false, + reasons: [], }) expect(run.next).toHaveBeenCalledOnce() }) @@ -355,7 +357,7 @@ describe('tanstack-start createPermix', () => { function createInstance( post: { create?: boolean; read?: boolean } = { create: true } ) { - const permix = createCorePermix() - permix.setup({ post: { create: false, read: false, ...post } }) - return permix + return createCorePermix().setup({ + post: { create: false, read: false, ...post }, + }) } diff --git a/permix/src/tanstack-start/permix.ts b/permix/src/tanstack-start/permix.ts index fc198840..fb7bd88f 100644 --- a/permix/src/tanstack-start/permix.ts +++ b/permix/src/tanstack-start/permix.ts @@ -6,12 +6,12 @@ import { createMiddleware } from '@tanstack/react-start' import type { Permix as PermixCore } from '../core' import { - createCheckContext, createHooks, createPermix as createPermixCore, createTemplate, PermixForbiddenError, PermixNotFoundError, + withDenialReasons, } from '../core' import type { CheckArgs, CheckContext } from '../core/check' import type { Definition } from '../core/definitions' @@ -57,8 +57,11 @@ function buildPermix( ) { const onForbidden = options.onForbidden ?? - (() => { - throw new PermixForbiddenError() + ((params) => { + throw new PermixForbiddenError({ + path: params.path, + reasons: params.reasons ?? [], + }) }) const hooks = createHooks>() @@ -130,7 +133,7 @@ function buildPermix( ? await callbackOrRules({ request }) : callbackOrRules - const instance = createPermixCore(rules) + const instance = createPermixCore().setup(rules) instance.hook('check', (context) => { hooks.callHook('check', context) }) @@ -178,7 +181,7 @@ function buildPermix( } else { return await onForbidden({ next, - ...createCheckContext(...args), + ...withDenialReasons(permix, args), }) } }) as unknown as ReturnType diff --git a/permix/src/trpc/permix.ts b/permix/src/trpc/permix.ts index 3c38f2a0..07b67609 100644 --- a/permix/src/trpc/permix.ts +++ b/permix/src/trpc/permix.ts @@ -3,11 +3,11 @@ import { initTRPC, TRPCError } from '@trpc/server' import type { Permix as PermixCore } from '../core' import { - createCheckContext, createHooks, createPermix as createPermixCore, createTemplate, PermixNotFoundError, + withDenialReasons, } from '../core' import type { CheckArgs, CheckContext } from '../core/check' import type { Definition } from '../core/definitions' @@ -48,7 +48,7 @@ function buildPermix( const t = initTRPC.context<{ [P in Key]: PermixCore }>().create() function setupContext(rules: Rules): { [P in Key]: PermixCore } { - const instance = createPermixCore(rules) + const instance = createPermixCore().setup(rules) instance.hook('check', (context) => { hooks.callHook('check', context) }) @@ -68,7 +68,7 @@ function buildPermix( return await opts.next() } - return forbiddenHandler({ ...opts, ...createCheckContext(...args) }) + return forbiddenHandler({ ...opts, ...withDenialReasons(instance, args) }) }) as TRPCMiddlewareBuilder< TrpcRootContext, object, diff --git a/permix/src/vue/components.test.ts b/permix/src/vue/components.test.ts index 83508d29..01d9c2e7 100644 --- a/permix/src/vue/components.test.ts +++ b/permix/src/vue/components.test.ts @@ -2,17 +2,16 @@ import { mount } from '@vue/test-utils' import { describe, expect, it } from 'vitest' import { createPermix, PermixRuleNotDefinedError } from '../core' -import { createComponents, PermixHydrate, PermixProvider } from './components' +import { createComponents } from './components' import { usePermix } from './composables' +import { PermixHydrate, PermixProvider } from './provider' import { mountWithPermix } from './test-utils' describe('components', () => { it('should check hydration', () => { const permixServer = createPermix<{ post: ['create', 'read'] - }>() - - permixServer.setup({ + }>().setup({ post: { create: true, read: false, @@ -50,12 +49,50 @@ describe('components', () => { expect(wrapper.text()).toBe('true') }) + it('uses dehydrated rules on the first render', () => { + const permixServer = createPermix<{ + post: ['create', 'read'] + }>().setup({ + post: { + create: true, + read: false, + }, + }) + + const dehydrated = permixServer.dehydrate() + const permixClient = createPermix<{ + post: ['create', 'read'] + }>() + + const TestComponent = { + template: "
{{ check('post.create') }}:{{ isReady }}
", + setup() { + const { check, isReady } = usePermix(permixClient) + return { check, isReady } + }, + } + + const wrapper = mount({ + template: ` + + + + + + `, + components: { PermixProvider, PermixHydrate, TestComponent }, + setup() { + return { permix: permixClient, dehydrated } + }, + }) + + expect(wrapper.text()).toBe('true:false') + }) + it('should work with Check component', () => { const permix = createPermix<{ post: ['create'] - }>() - - permix.setup({ + }>().setup({ post: { create: true, }, @@ -85,9 +122,7 @@ describe('components', () => { it('should work with Check component and data', () => { const permix = createPermix<{ post: [{ name: 'edit'; type: { authorId: string } }] - }>() - - permix.setup({ + }>().setup({ post: { edit: (post) => post?.authorId === '1', }, @@ -138,9 +173,7 @@ describe('components', () => { it('should work with Check component and DOM rerender', async () => { const permix = createPermix<{ post: ['read'] - }>() - - permix.setup({ + }>().setup({ post: { read: false, }, @@ -179,9 +212,7 @@ describe('components', () => { it('should work with reverse prop', async () => { const permix = createPermix<{ post: ['create'] - }>() - - permix.setup({ + }>().setup({ post: { create: true, }, @@ -228,9 +259,7 @@ describe('components', () => { it("shouldn't accept invalid props", () => { const permix = createPermix<{ post: ['create'] - }>() - - permix.setup({ + }>().setup({ post: { create: true, }, diff --git a/permix/src/vue/components.ts b/permix/src/vue/components.ts index 1230940a..ffb7aae6 100644 --- a/permix/src/vue/components.ts +++ b/permix/src/vue/components.ts @@ -1,31 +1,10 @@ -import type { PropType, SetupContext, SlotsType, VNode } from 'vue' -import { defineComponent, onUnmounted, watch } from 'vue' +import type { Component, InjectionKey, Ref } from 'vue' +import { computed, defineComponent } from 'vue' -import type { CheckArgs, Definition, DehydratedState, Permix } from '../core' +import type { CheckArgs, Definition, Permix } from '../core' import { usePermix } from './composables' -import { providePermixContext, usePermixContext } from './context' - -/** - * Provides Permix context to the Vue component tree. - * - * @link https://permix.letstri.dev/docs/integrations/vue - */ -export const PermixProvider = defineComponent({ - name: 'PermixProvider', - props: { - permix: { - type: Object as PropType>, - required: true, - }, - }, - setup(props, { slots }) { - const cleanup = providePermixContext(props.permix) - - onUnmounted(cleanup) - - return () => slots.default?.() - }, -}) +import type { PermixContext } from './context' +import { PERMIX_CONTEXT_KEY } from './context' export interface CheckProps { path: CheckArgs[0] @@ -33,84 +12,53 @@ export interface CheckProps { reverse?: boolean } -type CheckContext = SetupContext< - any, - SlotsType<{ - default: void - otherwise?: void - }> -> - export interface PermixComponents { - Check: ( - props: CheckProps, - context: CheckContext - ) => VNode | VNode[] | undefined + Check: Component> } -/** - * Restores dehydrated server permissions on the client. - * - * @link https://permix.letstri.dev/docs/integrations/vue - */ -export const PermixHydrate = defineComponent({ - name: 'PermixHydrate', - props: { - state: { - type: Object as PropType>, - required: true, - }, - }, - setup(props, { slots }) { - const context = usePermixContext() - - const hydrate = () => { - context.value.permix.hydrate(props.state) - } - - hydrate() - watch(() => props.state, hydrate) - - return () => slots.default?.() - }, -}) - export function createComponents( - permix: Pick, 'getRules' | 'check'> + permix: Pick, 'getRules' | 'check'>, + key: InjectionKey>> = PERMIX_CONTEXT_KEY ): PermixComponents { - function Check(props: CheckProps, context: CheckContext) { - const { check } = usePermix(permix) + const Check = defineComponent({ + name: 'Check', + inheritAttrs: false, + props: { + path: { + type: String, + required: true, + }, + data: { + type: Object, + required: false, + }, + reverse: { + type: Boolean, + required: false, + default: false, + }, + }, + setup(props, { slots }) { + const { check } = usePermix(permix, key) - const hasPermission = check( - ...([props.path, props.data] as unknown as CheckArgs) - ) - return props.reverse - ? hasPermission - ? context.slots.otherwise?.() - : context.slots.default?.() - : hasPermission - ? context.slots.default?.() - : context.slots.otherwise?.() - } + const hasPermission = computed(() => + check(...([props.path, props.data] as unknown as CheckArgs)) + ) - Check.inheritAttrs = false - Check.props = { - path: { - type: String, - required: true, + return () => { + const allowed = hasPermission.value + return props.reverse + ? allowed + ? slots.otherwise?.() + : slots.default?.() + : allowed + ? slots.default?.() + : slots.otherwise?.() + } }, - data: { - type: Object, - required: false, - }, - reverse: { - type: Boolean, - required: false, - default: false, - }, - } + }) return { - Check, + Check: Check as unknown as PermixComponents['Check'], } } diff --git a/permix/src/vue/composables.test.ts b/permix/src/vue/composables.test.ts index e9807aaf..b4c04e10 100644 --- a/permix/src/vue/composables.test.ts +++ b/permix/src/vue/composables.test.ts @@ -28,9 +28,7 @@ describe('composables', () => { it('should work with custom hook', () => { const permix = createPermix<{ post: ['create', 'read'] - }>() - - permix.setup({ + }>().setup({ post: { create: true, read: false, @@ -56,12 +54,32 @@ describe('composables', () => { expect(wrapper.get('[data-testid="read"]').text()).toBe('false') }) + it('reads ready state on the first render when setup ran before subscribe', () => { + const permix = createPermix<{ + post: ['create'] + }>().setup({ + post: { + create: true, + }, + }) + + const TestWrapper = defineComponent({ + template: '
{{ isReady }}:{{ check("post.create") }}
', + setup() { + const { check, isReady } = usePermix(permix) + return { check, isReady } + }, + }) + + const wrapper = mountWithPermix(TestWrapper, permix) + + expect(wrapper.get('div').text()).toBe('true:true') + }) + it('should work with DOM rerender', async () => { const permix = createPermix<{ post: [{ name: 'create'; type: { id: string } }, 'read'] - }>() - - permix.setup({ + }>().setup({ post: { create: (post) => post?.id === '1', read: false, diff --git a/permix/src/vue/composables.ts b/permix/src/vue/composables.ts index 135ad6f4..6ec04690 100644 --- a/permix/src/vue/composables.ts +++ b/permix/src/vue/composables.ts @@ -1,8 +1,10 @@ +import type { InjectionKey, Ref } from 'vue' import { computed } from 'vue' -import type { Definition, Permix, Rules } from '../core' -import { createCheck } from '../core' -import { usePermixContext } from './context' +import type { Definition, Permix } from '../core' +import { runCheck, runExplain } from '../core' +import type { PermixContext } from './context' +import { PERMIX_CONTEXT_KEY, usePermixContext } from './context' /** * Access Permix check and readiness state inside a Vue component. @@ -10,14 +12,16 @@ import { usePermixContext } from './context' * @link https://permix.letstri.dev/docs/integrations/vue */ export function usePermix( - permix: Pick, 'getRules' | 'check'> + _permix?: Pick, 'getRules' | 'check'>, + key: InjectionKey>> = PERMIX_CONTEXT_KEY ) { - const context = usePermixContext() + const context = usePermixContext(key) const check: Permix['check'] = (...args) => - createCheck( - () => (context.value.rules ?? permix.getRules()) as Rules | null - )(...args) + runCheck(context.value.permix, context.value.rules, ...args) - return { check, isReady: computed(() => context.value.isReady) } + const explain: Permix['explain'] = (...args) => + runExplain(context.value.permix, context.value.rules, ...args) + + return { check, explain, isReady: computed(() => context.value.isReady) } } diff --git a/permix/src/vue/context.ts b/permix/src/vue/context.ts index 214a3ce5..655ce0c8 100644 --- a/permix/src/vue/context.ts +++ b/permix/src/vue/context.ts @@ -9,11 +9,18 @@ export interface PermixContext { rules: Rules | null } -const PERMIX_CONTEXT_KEY = Symbol('vue-permix') as InjectionKey< +export const PERMIX_CONTEXT_KEY = Symbol('vue-permix') as InjectionKey< Ref> > -export function providePermixContext(permix: Permix) { +export function createPermixInjectionKey() { + return Symbol('vue-permix') as InjectionKey>> +} + +export function providePermixContext( + permix: Permix, + key: InjectionKey>> = PERMIX_CONTEXT_KEY +) { if (!permix) { throw new Error( '[Permix]: Looks like you forgot to provide the permix instance to PermixProvider' @@ -26,14 +33,22 @@ export function providePermixContext(permix: Permix) { isReady: permix.isReady(), }) - provide(PERMIX_CONTEXT_KEY, context) + provide(key, context) - const setup = permix.hook('setup', () => { - context.value.rules = permix.getRules() + const setup = permix.hook('setup', (instance) => { + context.value = { + permix: instance, + rules: instance.getRules(), + isReady: instance.isReady(), + } }) - const ready = permix.hook('ready', () => { - context.value.isReady = permix.isReady() + const ready = permix.hook('ready', (instance) => { + context.value = { + permix: instance, + rules: instance.getRules(), + isReady: instance.isReady(), + } }) return () => { @@ -42,8 +57,10 @@ export function providePermixContext(permix: Permix) { } } -export function usePermixContext() { - const context = inject(PERMIX_CONTEXT_KEY) +export function usePermixContext( + key: InjectionKey>> = PERMIX_CONTEXT_KEY +) { + const context = inject(key) if (!context) { throw new Error( @@ -51,5 +68,5 @@ export function usePermixContext() { ) } - return context as Ref> + return context } diff --git a/permix/src/vue/create-permix.test.ts b/permix/src/vue/create-permix.test.ts new file mode 100644 index 00000000..c6a2af77 --- /dev/null +++ b/permix/src/vue/create-permix.test.ts @@ -0,0 +1,90 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { defineComponent } from 'vue' + +import { createPermix as createCore } from '../core' +import { createPermix } from './create-permix' + +describe('vue factory contexts', () => { + it('creates bound UI without a permix prop', () => { + const { PermixProvider, usePermix, Check } = createPermix( + createCore<{ + post: ['read'] + }>().setup({ + post: { read: true }, + }) + ) + + const HookLabel = defineComponent({ + setup() { + const { check, isReady } = usePermix() + return { check, isReady } + }, + template: `{{ isReady }}:{{ check('post.read') }}`, + }) + + const App = defineComponent({ + components: { PermixProvider, HookLabel, Check }, + template: ` + + + allowed + + `, + }) + + const wrapper = mount(App) + expect(wrapper.get('[data-testid="hook"]').text()).toBe('true:true') + expect(wrapper.text()).toContain('allowed') + }) + + it('keeps nested factory contexts independent', () => { + const postsUI = createPermix( + createCore<{ post: ['read'] }>().setup({ post: { read: true } }) + ) + const commentsUI = createPermix( + createCore<{ comment: ['read'] }>().setup({ comment: { read: false } }) + ) + + const Nested = defineComponent({ + setup() { + const postsState = postsUI.usePermix() + const commentsState = commentsUI.usePermix() + return { postsState, commentsState } + }, + components: { + PostCheck: postsUI.Check, + CommentCheck: commentsUI.Check, + }, + template: ` +
+ {{ postsState.check('post.read') }} + {{ commentsState.check('comment.read') }} + post-ok + comment-ok +
+ `, + }) + + const App = defineComponent({ + components: { + PostsProvider: postsUI.PermixProvider, + CommentsProvider: commentsUI.PermixProvider, + Nested, + }, + template: ` + + + + + + `, + }) + + const wrapper = mount(App) + expect(wrapper.get('[data-testid="post"]').text()).toBe('true') + expect(wrapper.get('[data-testid="comment"]').text()).toBe('false') + expect(wrapper.get('[data-testid="post-check"]').text()).toBe('post-ok') + expect(wrapper.find('[data-testid="comment-check"]').exists()).toBe(false) + }) +}) diff --git a/permix/src/vue/create-permix.ts b/permix/src/vue/create-permix.ts new file mode 100644 index 00000000..8ea1048c --- /dev/null +++ b/permix/src/vue/create-permix.ts @@ -0,0 +1,96 @@ +import type { Component, InjectionKey, PropType, Ref } from 'vue' +import { defineComponent, onUnmounted, watch } from 'vue' + +import type { Definition, DehydratedState, Permix, Rules } from '../core' +import { createPermix as createPermixCore } from '../core' +import type { PermixComponents } from './components' +import { createComponents } from './components' +import { usePermix as usePermixFromContext } from './composables' +import type { PermixContext } from './context' +import { + createPermixInjectionKey, + providePermixContext, + usePermixContext, +} from './context' + +export interface CreatePermixResult { + permix: Permix + PermixProvider: Component + PermixHydrate: Component + usePermix: () => { + check: Permix['check'] + explain: Permix['explain'] + isReady: ReturnType>['isReady'] + } + Check: PermixComponents['Check'] +} + +/** + * Create a Permix instance with isolated Vue bindings. + * + * Call once at module scope. The bound provider does not take a `permix` + * prop. `usePermix()` takes no instance argument. + * + * @link https://permix.letstri.dev/docs/integrations/vue + */ +export function createPermix( + instance?: Permix +): CreatePermixResult { + const permix = instance ?? createPermixCore() + const key = createPermixInjectionKey() + const { Check } = createComponents(permix, key) + + const BoundProvider = defineComponent({ + name: 'PermixProvider', + setup(_, { slots }) { + const cleanup = providePermixContext( + permix, + key as InjectionKey>> + ) + onUnmounted(cleanup) + return () => slots.default?.() + }, + }) + + const BoundHydrate = defineComponent({ + name: 'PermixHydrate', + props: { + state: { + type: Object as PropType>, + required: true, + }, + }, + setup(props, { slots }) { + const context = usePermixContext(key) + + const hydrate = () => { + const state = props.state as DehydratedState + context.value.permix.hydrate(state) + context.value = { + permix: context.value.permix, + isReady: context.value.isReady, + rules: context.value.isReady + ? context.value.rules + : (state as Rules), + } + } + + hydrate() + watch(() => props.state, hydrate) + + return () => slots.default?.() + }, + }) + + function useBoundPermix() { + return usePermixFromContext(permix, key) + } + + return { + permix, + PermixProvider: BoundProvider, + PermixHydrate: BoundHydrate, + usePermix: useBoundPermix, + Check, + } +} diff --git a/permix/src/vue/index.ts b/permix/src/vue/index.ts index 337e56d5..2c5a96b9 100644 --- a/permix/src/vue/index.ts +++ b/permix/src/vue/index.ts @@ -1,4 +1,4 @@ -export { createComponents, PermixHydrate, PermixProvider } from './components' +export { createPermix } from './create-permix' +export type { CreatePermixResult } from './create-permix' export type { CheckProps, PermixComponents } from './components' -export { usePermix } from './composables' export type { PermixContext } from './context' diff --git a/permix/src/vue/provider.test.ts b/permix/src/vue/provider.test.ts index 9b2b61b8..b8a2226d 100644 --- a/permix/src/vue/provider.test.ts +++ b/permix/src/vue/provider.test.ts @@ -2,7 +2,7 @@ import { mount } from '@vue/test-utils' import { describe, expect, it } from 'vitest' import { createPermix } from '../core' -import { PermixProvider } from './components' +import { PermixProvider } from './provider' describe('permix vue provider', () => { it('should throw an error if permix instance is not provided', () => { diff --git a/permix/src/vue/provider.ts b/permix/src/vue/provider.ts new file mode 100644 index 00000000..e03c5f36 --- /dev/null +++ b/permix/src/vue/provider.ts @@ -0,0 +1,59 @@ +import { defineComponent, onUnmounted, watch } from 'vue' +import type { PropType } from 'vue' + +import type { DehydratedState, Permix } from '../core' +import { providePermixContext, usePermixContext } from './context' + +/** + * Provides Permix context to the Vue component tree. + * + * @link https://permix.letstri.dev/docs/integrations/vue + */ +export const PermixProvider = defineComponent({ + name: 'PermixProvider', + props: { + permix: { + type: Object as PropType>, + required: true, + }, + }, + setup(props, { slots }) { + const cleanup = providePermixContext(props.permix) + + onUnmounted(cleanup) + + return () => slots.default?.() + }, +}) + +/** + * Restores dehydrated server permissions on the client. + * + * @link https://permix.letstri.dev/docs/integrations/vue + */ +export const PermixHydrate = defineComponent({ + name: 'PermixHydrate', + props: { + state: { + type: Object as PropType>, + required: true, + }, + }, + setup(props, { slots }) { + const context = usePermixContext() + + const hydrate = () => { + context.value.permix.hydrate(props.state) + context.value = { + permix: context.value.permix, + isReady: context.value.isReady, + rules: context.value.isReady ? context.value.rules : props.state, + } + } + + hydrate() + watch(() => props.state, hydrate) + + return () => slots.default?.() + }, +}) diff --git a/permix/src/vue/test-utils.ts b/permix/src/vue/test-utils.ts index 11e601b3..ef189aa5 100644 --- a/permix/src/vue/test-utils.ts +++ b/permix/src/vue/test-utils.ts @@ -2,7 +2,7 @@ import type { VueWrapper } from '@vue/test-utils' import { mount } from '@vue/test-utils' import type { Permix } from '../core' -import { PermixProvider } from './components' +import { PermixProvider } from './provider' export function mountWithPermix( component: Parameters[0], diff --git a/permix/test-d/generated-permissions-consumer.ts b/permix/test-d/generated-permissions-consumer.ts new file mode 100644 index 00000000..31f5628a --- /dev/null +++ b/permix/test-d/generated-permissions-consumer.ts @@ -0,0 +1,72 @@ +import { action, createPermix } from 'permix' +import { createPermix as createNextPermix } from 'permix/next' +import { createPermix as createReactPermix } from 'permix/react' +import { z } from 'zod' + +import { + definePermissionConfig, + definePermissionOverlay, + permissions, +} from './generated-permissions' +import type { Definition } from './generated-permissions' + +const metadata = definePermissionConfig({ + 'tasks.comment': { + title: 'Comment on tasks', + }, +}) + +const taskSchema = z.object({ + taskId: z.string(), +}) + +const overlay = definePermissionOverlay({ + tasks: [ + action('comment', taskSchema, { + required: true, + }), + ], +}) + +type AppDefinition = Definition + +const core = createPermix() +const react = createReactPermix() +const next = createNextPermix(() => ({ + tasks: { + comment: ({ taskId }) => taskId.length > 0, + read: true, + }, + workspace: { + members: { + invite: true, + }, + }, +})) + +core.setup({ + tasks: { + comment: ({ taskId }) => taskId.length > 0, + read: true, + }, + workspace: { + members: { + invite: true, + }, + }, +}) + +core.check(permissions.tasks.comment, { taskId: 'task-1' }) +core.check(permissions.tasks.read) +core.check('tasks.~any') +core.check('workspace.~all') +core.check('~any') +core.check('~all') + +// @ts-expect-error Required overlay payload data cannot be omitted. +core.check(permissions.tasks.comment) + +// @ts-expect-error Unknown permissions are rejected by central metadata config. +definePermissionConfig({ 'tasks.delete': { title: 'Delete tasks' } }) + +export { core, metadata, next, react } diff --git a/permix/test-d/generated-permissions.ts b/permix/test-d/generated-permissions.ts new file mode 100644 index 00000000..a7dd276a --- /dev/null +++ b/permix/test-d/generated-permissions.ts @@ -0,0 +1,50 @@ +import { createPermissionConfig, createPermissionOverlay } from 'permix' +import type { + ApplyPermissionOverlay, + Definition as PermixDefinition, +} from 'permix' + +export type { PermissionReference } from 'permix/extractor' + +export const permissionKeys = [ + 'tasks.comment', + 'tasks.read', + 'workspace.members.invite', +] as const + +export type Permission = (typeof permissionKeys)[number] + +export const permissions = { + tasks: { + comment: 'tasks.comment', + read: 'tasks.read', + }, + workspace: { + members: { + invite: 'workspace.members.invite', + }, + }, +} as const + +export const permissionMetadata = { + 'tasks.comment': {}, + 'tasks.read': {}, + 'workspace.members.invite': {}, +} as const + +export const permissionDefinition = { + tasks: ['comment', 'read'], + workspace: { + members: ['invite'], + }, +} as const + +export type ExtractedDefinition = typeof permissionDefinition + +export type Definition = + ApplyPermissionOverlay + +export const definePermissionConfig = createPermissionConfig() + +export const definePermissionOverlay = + createPermissionOverlay() diff --git a/permix/test-d/public-api.ts b/permix/test-d/public-api.ts new file mode 100644 index 00000000..a06bcff2 --- /dev/null +++ b/permix/test-d/public-api.ts @@ -0,0 +1,81 @@ +import type { CheckArgs } from 'permix' +import { createPermix } from 'permix' +import { createPermix as createDrizzlePermix } from 'permix/drizzle' +import { createPermix as createEffectPermix } from 'permix/effect' +import { createPermix as createElysiaPermix } from 'permix/elysia' +import { createPermix as createExpressPermix } from 'permix/express' +import { createPermix as createFastifyPermix } from 'permix/fastify' +import { createPermix as createHonoPermix } from 'permix/hono' +import { createPermix as createNextPermix } from 'permix/next' +import { createPermix as createNodePermix } from 'permix/node' +import { createPermix as createOrpcPermix } from 'permix/orpc' +import { createPermix as createReactPermix } from 'permix/react' +import { createPermix as createServerPermix } from 'permix/server' +import { createPermix as createSolidPermix } from 'permix/solid' +import { createPermix as createSveltePermix } from 'permix/svelte' +import { createPermix as createTanstackStartPermix } from 'permix/tanstack-start' +import { createPermix as createTrpcPermix } from 'permix/trpc' +import { createPermix as createVuePermix } from 'permix/vue' + +type PostDefinition = { + post: ['create', 'read'] +} + +const core = createPermix() +const reactFactory = createReactPermix(core) +const reactStandalone = createReactPermix() +const vue = createVuePermix(core) +const trpc = createTrpcPermix() +const orpc = createOrpcPermix() +const express = createExpressPermix() +const hono = createHonoPermix() +const node = createNodePermix() +const server = createServerPermix() +const elysia = createElysiaPermix() +const fastify = createFastifyPermix() +const solid = createSolidPermix(core) +const svelte = createSveltePermix(core) +const drizzle = createDrizzlePermix({}) +const effect = createEffectPermix() +const next = createNextPermix(() => ({ + post: { + create: true, + read: true, + }, +})) +const tanstackStart = createTanstackStartPermix() + +core.setup({ + post: { + create: true, + read: true, + }, +}) + +const checkArgs: CheckArgs = ['post.create'] +const allowed = core.check(...checkArgs) +type FactoryCheck = ReturnType<(typeof reactFactory)['usePermix']>['check'] +const factoryCheck: FactoryCheck = core.check + +export { + allowed, + core, + drizzle, + effect, + elysia, + express, + factoryCheck, + fastify, + hono, + next, + node, + orpc, + reactFactory, + reactStandalone, + server, + solid, + svelte, + tanstackStart, + trpc, + vue, +} diff --git a/permix/test/next/.gitignore b/permix/test/next/.gitignore new file mode 100644 index 00000000..90d593e7 --- /dev/null +++ b/permix/test/next/.gitignore @@ -0,0 +1,4 @@ +.scratch +test-results +playwright-report +blob-report diff --git a/permix/test/next/instant-nav.rig.md b/permix/test/next/instant-nav.rig.md new file mode 100644 index 00000000..f2987b10 --- /dev/null +++ b/permix/test/next/instant-nav.rig.md @@ -0,0 +1,10 @@ +# instant-nav rig: permix Next.js adapter fixtures + +- BUILD: `EXPOSE_TESTING_API=1` plus the versioned `next` binary from this package (`next-15`, `next-16-0`, `next-16-3`) run as `next build && next start` inside `permix/test/next/.scratch/`. Never `next dev`. +- EXPOSE: `EXPOSE_TESTING_API=1` at **build** time, wired to `experimental.exposeTestingApiInProductionBuild` in the 16.3 overlay config. Compat versions (15.5.24, 16.0.11) do not run `instant()`. +- RUN: `pnpm --filter @permix/next-integration test` (or `node permix/test/next/run.mjs` after `pnpm --filter permix build`). Playwright uses `BASE_URL` from the launcher. Desktop 1280×720 and mobile 390×844 projects run the 16.3 instant-navigation file. +- TEST USER: cookie `demo-user=alice` (can create/update) or `demo-user=bob` (read only). No login helper; tests call `context.addCookies`. Public/cache-safe checks do not depend on the cookie. +- DRIFT: cookie value, tenant root param (`acme` vs `globex`), whether a `"use cache: private"` payload was primed, Partial Prefetching vs `prefetch={true}`. The cold session island uses `connection()` so the testing lock can gate it; cookie reads alone are not a lock probe. +- LOOP: local `build → start → playwright` via `run.mjs`; CI job `.github/workflows/next-integration.yml` does the same. Fully agent-drivable; no secrets. +- LIVENESS: n/a — each run builds and starts a local server on a free port, then stops that process group. +- WALLS: `permix` must be built (`pnpm --filter permix build`) so the fixture can import `permix`/`permix/next`. Playwright Chromium is installed with `pnpm exec playwright install chromium`. Next 15/16.0 configs must not set `cacheComponents` or `partialPrefetching`. diff --git a/permix/test/next/package.json b/permix/test/next/package.json new file mode 100644 index 00000000..671bf310 --- /dev/null +++ b/permix/test/next/package.json @@ -0,0 +1,25 @@ +{ + "name": "@permix/next-integration", + "private": true, + "type": "module", + "scripts": { + "test": "node ./run.mjs" + }, + "dependencies": { + "permix": "workspace:*", + "react": "catalog:", + "react-dom": "catalog:" + }, + "devDependencies": { + "@next/playwright": "16.3.3", + "@playwright/test": "^1.55.1", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "next-15": "npm:next@15.5.24", + "next-16-0": "npm:next@16.0.11", + "next-16-3": "npm:next@16.3.3", + "typescript": "catalog:", + "typescript59": "catalog:typescript-classic" + } +} diff --git a/permix/test/next/playwright.config.ts b/permix/test/next/playwright.config.ts new file mode 100644 index 00000000..17224ef9 --- /dev/null +++ b/permix/test/next/playwright.config.ts @@ -0,0 +1,36 @@ +import { defineConfig } from '@playwright/test' + +const baseURL = process.env.BASE_URL ?? 'http://127.0.0.1:3000' +const isPpr = process.env.PERMIX_NEXT_VERSION === '16.3.3' + +export default defineConfig({ + testDir: './tests', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: 0, + use: { + baseURL, + trace: 'off', + }, + projects: [ + { + name: 'compat', + testMatch: /compat\.spec\.ts/, + use: { viewport: { width: 1280, height: 720 } }, + }, + ...(isPpr + ? [ + { + name: 'desktop', + testMatch: /instant\.spec\.ts/, + use: { viewport: { width: 1280, height: 720 } }, + }, + { + name: 'mobile', + testMatch: /instant\.spec\.ts/, + use: { viewport: { width: 390, height: 844 } }, + }, + ] + : []), + ], +}) diff --git a/permix/test/next/run.mjs b/permix/test/next/run.mjs new file mode 100644 index 00000000..b5b42d3f --- /dev/null +++ b/permix/test/next/run.mjs @@ -0,0 +1,195 @@ +import { spawn } from 'node:child_process' +import { cp, mkdir, readFile, rm, symlink } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { createServer } from 'node:net' +import path from 'node:path' + +const here = import.meta.dirname +const require = createRequire(import.meta.url) +const permixRoot = path.resolve(here, '../..') + +const versions = [ + { id: '15.5.24', alias: 'next-15', ppr: false, typescript: 'typescript59' }, + { id: '16.0.11', alias: 'next-16-0', ppr: false, typescript: 'typescript59' }, + { id: '16.3.3', alias: 'next-16-3', ppr: true, typescript: 'typescript' }, +] + +function packageDir(specifier) { + return path.dirname(require.resolve(`${specifier}/package.json`)) +} + +function getFreePort() { + return new Promise((resolve, reject) => { + const server = createServer() + server.listen(0, '127.0.0.1', () => { + const address = server.address() + if (!address || typeof address === 'string') { + server.close() + reject(new Error('Failed to allocate a port')) + return + } + const port = address.port + server.close((error) => { + if (error) { + reject(error) + return + } + resolve(port) + }) + }) + server.on('error', reject) + }) +} + +function run(command, args, options) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: 'inherit', + ...options, + }) + child.on('error', reject) + child.on('exit', (code, signal) => { + if (code === 0) { + resolve() + return + } + reject( + new Error(`${command} ${args.join(' ')} failed (${code ?? signal})`) + ) + }) + }) +} + +async function waitForServer(url, timeoutMs = 60_000) { + const start = Date.now() + while (Date.now() - start < timeoutMs) { + try { + const response = await fetch(url, { redirect: 'manual' }) + await response.arrayBuffer() + return + } catch { + await new Promise((resolve) => setTimeout(resolve, 250)) + } + } + throw new Error(`Timed out waiting for ${url}`) +} + +async function prepare(version) { + const dest = path.join(here, '.scratch', version.id) + await rm(dest, { recursive: true, force: true }) + await cp(path.join(here, 'src'), dest, { recursive: true }) + if (version.ppr) { + await cp(path.join(here, 'src-ppr'), dest, { recursive: true }) + await rm(path.join(dest, 'app/layout.tsx'), { force: true }) + await rm(path.join(dest, 'app/page.tsx'), { force: true }) + } + + const modules = path.join(dest, 'node_modules') + await mkdir(modules, { recursive: true }) + await symlink(packageDir(version.alias), path.join(modules, 'next')) + await symlink(packageDir('react'), path.join(modules, 'react')) + await symlink(packageDir('react-dom'), path.join(modules, 'react-dom')) + await symlink(packageDir(version.typescript), path.join(modules, 'typescript')) + await symlink(permixRoot, path.join(modules, 'permix')) + return dest +} + +async function nextBin(alias) { + const nextPackage = packageDir(alias) + return path.join(nextPackage, 'dist/bin/next') +} + +async function assertPermissionCatalog(dest) { + const output = path.join(dest, '.permix/permissions.json') + const catalog = JSON.parse(await readFile(output, 'utf-8')) + const hasIntegrationPermission = catalog.permissions.some( + (entry) => entry.key === 'integration.read' + ) + if (!hasIntegrationPermission) { + throw new Error(`Missing integration.read in ${output}`) + } +} + +async function withServer(version, dest, fn) { + const port = await getFreePort() + const baseURL = `http://127.0.0.1:${port}` + const bin = await nextBin(version.alias) + const child = spawn(process.execPath, [bin, 'start', '-p', String(port)], { + cwd: dest, + env: { ...process.env, PORT: String(port) }, + stdio: 'inherit', + detached: process.platform !== 'win32', + }) + + const stop = async () => { + if (child.pid && process.platform !== 'win32') { + try { + process.kill(-child.pid, 'SIGTERM') + } catch { + child.kill('SIGTERM') + } + } else { + child.kill('SIGTERM') + } + } + + try { + await waitForServer(baseURL) + await fn(baseURL) + } finally { + await stop() + } +} + +async function main() { + const dist = path.join(permixRoot, 'dist/core/index.mjs') + try { + await import(path.join(permixRoot, 'dist/next/index.mjs')) + } catch { + throw new Error( + `permix must be built before running Next fixtures (missing ${dist}). Run pnpm --filter permix build.` + ) + } + + const only = process.env.PERMIX_NEXT_VERSION + const selected = only + ? versions.filter((version) => version.id === only) + : versions + if (only && selected.length === 0) { + throw new Error(`Unknown PERMIX_NEXT_VERSION: ${only}`) + } + + for (const version of selected) { + console.log(`\n=== Next ${version.id} ===`) + const dest = await prepare(version) + const bin = await nextBin(version.alias) + await run(process.execPath, [bin, 'build'], { + cwd: dest, + env: { + ...process.env, + EXPOSE_TESTING_API: '1', + PERMIX_NEXT_VERSION: version.id, + }, + }) + await assertPermissionCatalog(dest) + await withServer(version, dest, async (baseURL) => { + await run( + 'pnpm', + ['exec', 'playwright', 'test', '--config', 'playwright.config.ts'], + { + cwd: here, + env: { + ...process.env, + BASE_URL: baseURL, + PERMIX_NEXT_VERSION: version.id, + }, + } + ) + }) + } +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/permix/test/next/src-ppr/app/[tenant]/dashboard/page.tsx b/permix/test/next/src-ppr/app/[tenant]/dashboard/page.tsx new file mode 100644 index 00000000..9809fe2b --- /dev/null +++ b/permix/test/next/src-ppr/app/[tenant]/dashboard/page.tsx @@ -0,0 +1,10 @@ +import { PermissionHoles } from '../../features/permission-holes' + +export default function DashboardPage() { + return ( +
+

Dashboard

+ +
+ ) +} diff --git a/permix/test/next/src-ppr/app/[tenant]/layout.tsx b/permix/test/next/src-ppr/app/[tenant]/layout.tsx new file mode 100644 index 00000000..d3516983 --- /dev/null +++ b/permix/test/next/src-ppr/app/[tenant]/layout.tsx @@ -0,0 +1,59 @@ +import Link from 'next/link' +import type { ReactNode } from 'react' +import { Suspense } from 'react' + +import { CacheSafeCheck } from '../features/cache-safe-check' + +export function generateStaticParams() { + return [{ tenant: 'acme' }, { tenant: 'globex' }] +} + +export default function TenantLayout({ + children, + params, +}: { + children: ReactNode + params: Promise<{ tenant: string }> +}) { + return ( + + +
Permix Next fixture
+
+ + loading cache-safe

+ } + > + +
+ {children} +
+ + + ) +} diff --git a/permix/test/next/src-ppr/app/[tenant]/page.tsx b/permix/test/next/src-ppr/app/[tenant]/page.tsx new file mode 100644 index 00000000..90a68246 --- /dev/null +++ b/permix/test/next/src-ppr/app/[tenant]/page.tsx @@ -0,0 +1,44 @@ +import { Suspense } from 'react' + +import { ActionCheck } from '../features/action-check' +import { ConcurrentInstances } from '../features/concurrent-instances' +import { PermissionHoles } from '../features/permission-holes' +import { PublicRead } from '../features/public-read' +import { SessionCreate } from '../features/session-create' +import { UsePermixCreate } from '../features/use-permix-create' + +export default function TenantHome({ + params, +}: { + params: Promise<{ tenant: string }> +}) { + return ( +
+ {params.then(({ tenant }) => ( +

+ Tenant home +

+ ))} + loading public

}> + +
+ loading instances

} + > + +
+ loading session

} + > + +
+ loading usePermix

} + > + +
+ + +
+ ) +} diff --git a/permix/test/next/src-ppr/app/features/cache-safe-check.tsx b/permix/test/next/src-ppr/app/features/cache-safe-check.tsx new file mode 100644 index 00000000..ad4765ee --- /dev/null +++ b/permix/test/next/src-ppr/app/features/cache-safe-check.tsx @@ -0,0 +1,13 @@ +import { tenant } from 'next/root-params' + +import { tenantPermix } from '../../lib/tenant-permix' + +export async function CacheSafeCheck() { + const current = await tenant() + const canCreate = await tenantPermix.check('post.create') + return ( + + {current}:{canCreate ? 'create-allowed' : 'create-denied'} + + ) +} diff --git a/permix/test/next/src-ppr/app/features/permission-holes.tsx b/permix/test/next/src-ppr/app/features/permission-holes.tsx new file mode 100644 index 00000000..3a59d845 --- /dev/null +++ b/permix/test/next/src-ppr/app/features/permission-holes.tsx @@ -0,0 +1,21 @@ +import { Suspense } from 'react' + +import { PrivateEdit } from './private-edit' +import { SessionIsland } from './session-island' + +export function PermissionHoles() { + return ( + <> + loading session

} + > + +
+ loading private

} + > + +
+ + ) +} diff --git a/permix/test/next/src-ppr/app/features/private-edit.tsx b/permix/test/next/src-ppr/app/features/private-edit.tsx new file mode 100644 index 00000000..56c0ecdc --- /dev/null +++ b/permix/test/next/src-ppr/app/features/private-edit.tsx @@ -0,0 +1,23 @@ +import { createPermix } from 'permix' + +import { getUser } from '../../lib/auth' +import type { PostDefinition } from '../../lib/permix' +import { rulesForUser } from '../../lib/permix' + +async function readPrivateEditPayload() { + 'use cache: private' + const user = await getUser() + const permix = createPermix().setup(rulesForUser(user)) + if (!permix.check('post.update')) { + return null + } + return { canEdit: true as const, user } +} + +export async function PrivateEdit() { + const payload = await readPrivateEditPayload() + if (!payload) { + return hidden + } + return {payload.user}:edit-allowed +} diff --git a/permix/test/next/src-ppr/app/features/session-island.tsx b/permix/test/next/src-ppr/app/features/session-island.tsx new file mode 100644 index 00000000..c20c6b5f --- /dev/null +++ b/permix/test/next/src-ppr/app/features/session-island.tsx @@ -0,0 +1,13 @@ +import { connection } from 'next/server' + +import { permix } from '../../lib/permix' + +export async function SessionIsland() { + await connection() + const allowed = await permix.check('post.create') + return ( + + {allowed ? 'session-allowed' : 'session-denied'} + + ) +} diff --git a/permix/test/next/src-ppr/lib/tenant-permix.ts b/permix/test/next/src-ppr/lib/tenant-permix.ts new file mode 100644 index 00000000..9edb004c --- /dev/null +++ b/permix/test/next/src-ppr/lib/tenant-permix.ts @@ -0,0 +1,15 @@ +import { tenant } from 'next/root-params' +import { createPermix } from 'permix/next' + +import type { PostDefinition } from './permix' + +export const tenantPermix = createPermix(async () => { + const current = await tenant() + return { + post: { + create: current === 'acme', + read: true, + update: false, + }, + } +}) diff --git a/permix/test/next/src-ppr/next.config.mjs b/permix/test/next/src-ppr/next.config.mjs new file mode 100644 index 00000000..92a65320 --- /dev/null +++ b/permix/test/next/src-ppr/next.config.mjs @@ -0,0 +1,23 @@ +import { withPermix } from 'permix/next/config' + +const exposeTestingApi = process.env.EXPOSE_TESTING_API === '1' + +/** @type {import('next').NextConfig} */ +const nextConfig = { + transpilePackages: ['permix'], + cacheComponents: true, + partialPrefetching: true, + typescript: { + ignoreBuildErrors: true, + }, + experimental: exposeTestingApi + ? { + exposeTestingApiInProductionBuild: true, + } + : undefined, +} + +export default withPermix(nextConfig, { + include: ['app/**/*.{ts,tsx}'], + watch: false, +}) diff --git a/permix/test/next/src-ppr/root-params.d.ts b/permix/test/next/src-ppr/root-params.d.ts new file mode 100644 index 00000000..0425bd01 --- /dev/null +++ b/permix/test/next/src-ppr/root-params.d.ts @@ -0,0 +1,3 @@ +declare module 'next/root-params' { + export function tenant(): Promise +} diff --git a/permix/test/next/src/app/actions.ts b/permix/test/next/src/app/actions.ts new file mode 100644 index 00000000..09a51787 --- /dev/null +++ b/permix/test/next/src/app/actions.ts @@ -0,0 +1,14 @@ +'use server' + +import { createPermix } from 'permix' + +import { getUser } from '../lib/auth' +import type { PostDefinition } from '../lib/permix' +import { rulesForUser } from '../lib/permix' + +export async function checkCreate() { + const permix = createPermix().setup( + rulesForUser(await getUser()) + ) + return permix.check('post.create') +} diff --git a/permix/test/next/src/app/api/check/route.ts b/permix/test/next/src/app/api/check/route.ts new file mode 100644 index 00000000..102cbdb8 --- /dev/null +++ b/permix/test/next/src/app/api/check/route.ts @@ -0,0 +1,15 @@ +import { createPermix } from 'permix' + +import { getUser } from '../../../lib/auth' +import type { PostDefinition } from '../../../lib/permix' +import { rulesForUser } from '../../../lib/permix' + +export async function GET() { + const permix = createPermix().setup( + rulesForUser(await getUser()) + ) + return Response.json({ + create: permix.check('post.create'), + read: permix.check('post.read'), + }) +} diff --git a/permix/test/next/src/app/features/action-check.tsx b/permix/test/next/src/app/features/action-check.tsx new file mode 100644 index 00000000..1350bd9e --- /dev/null +++ b/permix/test/next/src/app/features/action-check.tsx @@ -0,0 +1,25 @@ +'use client' + +import { useState } from 'react' + +import { checkCreate } from '../actions' + +export function ActionCheck() { + const [result, setResult] = useState('idle') + + return ( +
+ + {result} +
+ ) +} diff --git a/permix/test/next/src/app/features/concurrent-instances.tsx b/permix/test/next/src/app/features/concurrent-instances.tsx new file mode 100644 index 00000000..040a7802 --- /dev/null +++ b/permix/test/next/src/app/features/concurrent-instances.tsx @@ -0,0 +1,16 @@ +import { instanceId } from '../../lib/instance-id' +import { permix } from '../../lib/permix' + +export async function ConcurrentInstances() { + return ( +
+ + +
+ ) +} + +async function InstanceSlot({ testId }: { testId: string }) { + const instance = await permix.getPermix() + return {String(instanceId(instance))} +} diff --git a/permix/test/next/src/app/features/public-read.tsx b/permix/test/next/src/app/features/public-read.tsx new file mode 100644 index 00000000..30cfc296 --- /dev/null +++ b/permix/test/next/src/app/features/public-read.tsx @@ -0,0 +1,6 @@ +import { publicPermix } from '../../lib/permix' + +export async function PublicRead() { + const allowed = await publicPermix.check('post.read') + return {allowed ? 'allowed' : 'denied'} +} diff --git a/permix/test/next/src/app/features/session-create.tsx b/permix/test/next/src/app/features/session-create.tsx new file mode 100644 index 00000000..600f66d5 --- /dev/null +++ b/permix/test/next/src/app/features/session-create.tsx @@ -0,0 +1,8 @@ +import { permix } from '../../lib/permix' + +export async function SessionCreate() { + const allowed = await permix.check('post.create') + return ( + {allowed ? 'allowed' : 'denied'} + ) +} diff --git a/permix/test/next/src/app/features/use-permix-create.tsx b/permix/test/next/src/app/features/use-permix-create.tsx new file mode 100644 index 00000000..81daeca1 --- /dev/null +++ b/permix/test/next/src/app/features/use-permix-create.tsx @@ -0,0 +1,10 @@ +import { permix } from '../../lib/permix' + +export function UsePermixCreate() { + const instance = permix.usePermix() + return ( + + {instance.check('post.create') ? 'allowed' : 'denied'} + + ) +} diff --git a/permix/test/next/src/app/layout.tsx b/permix/test/next/src/app/layout.tsx new file mode 100644 index 00000000..31b8e40b --- /dev/null +++ b/permix/test/next/src/app/layout.tsx @@ -0,0 +1,16 @@ +import type { ReactNode } from 'react' + +export default function RootLayout({ + children, +}: { + children: ReactNode +}) { + return ( + + +
Permix Next fixture
+ {children} + + + ) +} diff --git a/permix/test/next/src/app/page.tsx b/permix/test/next/src/app/page.tsx new file mode 100644 index 00000000..426bab85 --- /dev/null +++ b/permix/test/next/src/app/page.tsx @@ -0,0 +1,33 @@ +import { Suspense } from 'react' + +import { ActionCheck } from './features/action-check' +import { ConcurrentInstances } from './features/concurrent-instances' +import { PublicRead } from './features/public-read' +import { SessionCreate } from './features/session-create' +import { UsePermixCreate } from './features/use-permix-create' + +export default function Home() { + return ( +
+ loading public

}> + +
+ loading instances

} + > + +
+ loading session

} + > + +
+ loading usePermix

} + > + +
+ +
+ ) +} diff --git a/permix/test/next/src/app/permission-marker.ts b/permix/test/next/src/app/permission-marker.ts new file mode 100644 index 00000000..a120dfbd --- /dev/null +++ b/permix/test/next/src/app/permission-marker.ts @@ -0,0 +1,3 @@ +import { permission } from 'permix' + +export const integrationPermission = permission('integration.read') diff --git a/permix/test/next/src/lib/auth.ts b/permix/test/next/src/lib/auth.ts new file mode 100644 index 00000000..129d5907 --- /dev/null +++ b/permix/test/next/src/lib/auth.ts @@ -0,0 +1,9 @@ +import { cookies } from 'next/headers' + +export type DemoUser = 'alice' | 'bob' + +export async function getUser(): Promise { + const store = await cookies() + const value = store.get('demo-user')?.value + return value === 'bob' ? 'bob' : 'alice' +} diff --git a/permix/test/next/src/lib/instance-id.ts b/permix/test/next/src/lib/instance-id.ts new file mode 100644 index 00000000..8c490d67 --- /dev/null +++ b/permix/test/next/src/lib/instance-id.ts @@ -0,0 +1,12 @@ +const ids = new WeakMap() +let sequence = 0 + +export function instanceId(value: object): number { + const existing = ids.get(value) + if (existing !== undefined) { + return existing + } + sequence += 1 + ids.set(value, sequence) + return sequence +} diff --git a/permix/test/next/src/lib/permix.ts b/permix/test/next/src/lib/permix.ts new file mode 100644 index 00000000..cad941e3 --- /dev/null +++ b/permix/test/next/src/lib/permix.ts @@ -0,0 +1,29 @@ +import { createPermix } from 'permix/next' + +import { getUser } from './auth' + +export type PostDefinition = { + post: ['create', 'read', 'update'] +} + +export function rulesForUser(user: 'alice' | 'bob') { + return { + post: { + create: user === 'alice', + read: true, + update: user === 'alice', + }, + } as const +} + +export const permix = createPermix(async () => + rulesForUser(await getUser()) +) + +export const publicPermix = createPermix(() => ({ + post: { + create: false, + read: true, + update: false, + }, +})) diff --git a/permix/test/next/src/next-env.d.ts b/permix/test/next/src/next-env.d.ts new file mode 100644 index 00000000..6080addc --- /dev/null +++ b/permix/test/next/src/next-env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/permix/test/next/src/next.config.mjs b/permix/test/next/src/next.config.mjs new file mode 100644 index 00000000..f84d231c --- /dev/null +++ b/permix/test/next/src/next.config.mjs @@ -0,0 +1,17 @@ +import { withPermix } from 'permix/next/config' + +/** @type {import('next').NextConfig} */ +const nextConfig = { + transpilePackages: ['permix'], + typescript: { + ignoreBuildErrors: true, + }, + eslint: { + ignoreDuringBuilds: true, + }, +} + +export default withPermix(nextConfig, { + include: ['app/**/*.{ts,tsx}'], + watch: false, +}) diff --git a/permix/test/next/src/package.json b/permix/test/next/src/package.json new file mode 100644 index 00000000..72730e1d --- /dev/null +++ b/permix/test/next/src/package.json @@ -0,0 +1,5 @@ +{ + "name": "permix-next-fixture", + "private": true, + "type": "module" +} diff --git a/permix/test/next/src/tsconfig.json b/permix/test/next/src/tsconfig.json new file mode 100644 index 00000000..4fc71f3a --- /dev/null +++ b/permix/test/next/src/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "jsx": "react-jsx", + "module": "esnext", + "moduleResolution": "bundler", + "paths": { + "@/*": ["./*"] + }, + "resolveJsonModule": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "isolatedModules": true, + "skipLibCheck": true, + "plugins": [{ "name": "next" }] + }, + "include": ["**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/permix/test/next/tests/compat.spec.ts b/permix/test/next/tests/compat.spec.ts new file mode 100644 index 00000000..9f4037be --- /dev/null +++ b/permix/test/next/tests/compat.spec.ts @@ -0,0 +1,98 @@ +import { expect, test, type Page } from '@playwright/test' + +const homePath = + process.env.PERMIX_NEXT_VERSION === '16.3.3' ? '/acme' : '/' + +async function setUser(page: Page, user: 'alice' | 'bob') { + const baseURL = test.info().project.use.baseURL + if (!baseURL) { + throw new Error('BASE_URL is required') + } + const url = new URL(baseURL) + await page.context().addCookies([ + { + name: 'demo-user', + value: user, + domain: url.hostname, + path: '/', + }, + ]) +} + +test.describe('next adapter request state', () => { + test('concurrent RSC callers share one initialized instance', async ({ + page, + }) => { + await setUser(page, 'alice') + await page.goto(homePath) + await expect(page.getByTestId('instance-a')).not.toHaveText('') + await expect(page.getByTestId('instance-b')).toHaveText( + await page.getByTestId('instance-a').innerText() + ) + await expect(page.getByTestId('use-permix-create')).toHaveText('allowed') + await expect(page.getByTestId('session-create')).toHaveText('allowed') + await expect(page.getByTestId('public-read')).toHaveText('allowed') + }) + + test('concurrent requests stay isolated', async ({ browser, baseURL }) => { + if (!baseURL) { + throw new Error('BASE_URL is required') + } + const url = new URL(baseURL) + + async function openAs(user: 'alice' | 'bob') { + const context = await browser.newContext() + await context.addCookies([ + { + name: 'demo-user', + value: user, + domain: url.hostname, + path: '/', + }, + ]) + const page = await context.newPage() + await page.goto(homePath) + return { context, page } + } + + const alice = await openAs('alice') + const bob = await openAs('bob') + + await expect(alice.page.getByTestId('session-create')).toHaveText('allowed') + await expect(bob.page.getByTestId('session-create')).toHaveText('denied') + await expect(alice.page.getByTestId('use-permix-create')).toHaveText( + 'allowed' + ) + await expect(bob.page.getByTestId('use-permix-create')).toHaveText('denied') + + await alice.context.close() + await bob.context.close() + }) + + test('route handler sets up an explicit core instance', async ({ + page, + baseURL, + }) => { + await setUser(page, 'alice') + const alice = await page.request.get(`${baseURL}/api/check`) + expect(await alice.json()).toEqual({ create: true, read: true }) + + await page.context().clearCookies() + await setUser(page, 'bob') + const bob = await page.request.get(`${baseURL}/api/check`) + expect(await bob.json()).toEqual({ create: false, read: true }) + }) + + test('server action sets up an explicit core instance', async ({ page }) => { + await setUser(page, 'alice') + await page.goto(homePath) + await page.getByTestId('action-check').click() + await expect(page.getByTestId('action-result')).toHaveText('allowed') + + await page.context().clearCookies() + await setUser(page, 'bob') + await page.reload() + await page.getByTestId('action-check').click() + await expect(page.getByTestId('action-result')).toHaveText('denied') + }) +}) diff --git a/permix/test/next/tests/instant.spec.ts b/permix/test/next/tests/instant.spec.ts new file mode 100644 index 00000000..defcfde6 --- /dev/null +++ b/permix/test/next/tests/instant.spec.ts @@ -0,0 +1,109 @@ +import { instant } from '@next/playwright' +import { expect, test, type Page } from '@playwright/test' + +async function setUser(page: Page, user: 'alice' | 'bob') { + const baseURL = test.info().project.use.baseURL + if (!baseURL) { + throw new Error('BASE_URL is required') + } + const url = new URL(baseURL) + await page.context().addCookies([ + { + name: 'demo-user', + value: user, + domain: url.hostname, + path: '/', + }, + ]) +} + +function dashboardMain(page: Page) { + return page + .locator('main') + .filter({ has: page.getByTestId('dashboard-shell') }) +} + +function tenantShell(page: Page, tenant: 'acme' | 'globex') { + return page.getByTestId('tenant-shell').filter({ + has: page.getByTestId('tenant-name').filter({ hasText: tenant }), + }) +} + +test.describe('next 16.3 instant navigation', () => { + test('cache-safe permission UI is present under the lock', async ({ + page, + baseURL, + }) => { + await setUser(page, 'alice') + await instant( + page, + async () => { + await page.goto('/acme') + await expect(page.getByTestId('app-shell')).toBeVisible() + await expect(page.getByTestId('tenant-shell')).toBeVisible() + await expect(page.getByText('acme:create-allowed')).toBeVisible() + }, + { baseURL } + ) + }) + + test('cold session island is gated then streams', async ({ page }) => { + await setUser(page, 'alice') + await page.goto('/acme') + await expect(page.getByTestId('tenant-home')).toBeVisible() + + const dest = dashboardMain(page) + await instant(page, async () => { + await page.getByTestId('dashboard-link').click() + await expect(dest.getByTestId('dashboard-shell')).toBeVisible() + await expect(dest.getByTestId('session-island')).toHaveCount(0) + }) + + await expect(dest.getByTestId('session-island')).toBeVisible() + }) + + test('primed private payload is present immediately', async ({ page }) => { + await setUser(page, 'alice') + await page.goto('/acme') + await expect(page.getByTestId('private-edit')).toHaveText( + 'alice:edit-allowed' + ) + + const dest = dashboardMain(page) + await instant(page, async () => { + await page.getByTestId('dashboard-prefetch-link').click() + await expect(dest.getByTestId('dashboard-shell')).toBeVisible() + await expect(dest.getByTestId('private-edit')).toHaveText( + 'alice:edit-allowed' + ) + }) + }) + + test('default shared-shell navigation keeps tenant chrome instant', async ({ + page, + }) => { + await setUser(page, 'alice') + await page.goto('/acme') + await expect(page.getByTestId('tenant-home')).toBeVisible() + + await instant(page, async () => { + await page.getByTestId('globex-link').click() + await expect(tenantShell(page, 'globex')).toBeVisible() + await expect(page.getByText('globex:create-denied')).toBeVisible() + }) + }) + + test('prefetch=true root-param navigation includes tenant-keyed UI', async ({ + page, + }) => { + await setUser(page, 'alice') + await page.goto('/acme') + await expect(page.getByTestId('tenant-home')).toBeVisible() + + await instant(page, async () => { + await page.getByTestId('globex-prefetch-link').click() + await expect(tenantShell(page, 'globex')).toBeVisible() + await expect(page.getByText('globex:create-denied')).toBeVisible() + }) + }) +}) diff --git a/permix/tsconfig.base.json b/permix/tsconfig.base.json index cf0229e7..0346a479 100644 --- a/permix/tsconfig.base.json +++ b/permix/tsconfig.base.json @@ -1,13 +1,28 @@ { "compilerOptions": { "target": "ESNext", + "lib": ["ESNext"], "jsx": "preserve", "module": "ESNext", "moduleResolution": "Bundler", + "moduleDetection": "force", "strict": true, + "exactOptionalPropertyTypes": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "erasableSyntaxOnly": true, "noEmit": true, "forceConsistentCasingInFileNames": true, - "skipLibCheck": true + "skipLibCheck": true, + "types": [], + "experimentalDecorators": true }, "include": ["src/**/*.ts", "src/**/*.tsx", "*.ts"], "exclude": [ @@ -20,6 +35,8 @@ "src/tanstack-start/*.ts", "src/tanstack-start/*.tsx", "src/svelte/**/*.ts", - "src/svelte/**/*.svelte" + "src/svelte/**/*.svelte", + "test-d/**", + "scripts/**" ] } diff --git a/permix/tsconfig.compat.json b/permix/tsconfig.compat.json new file mode 100644 index 00000000..f373133c --- /dev/null +++ b/permix/tsconfig.compat.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "Preserve", + "moduleResolution": "bundler", + "moduleDetection": "force", + "strict": true, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noUncheckedSideEffectImports": true, + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "erasableSyntaxOnly": true, + "noEmit": true, + "skipLibCheck": true, + "types": [] + }, + "include": ["test-d/**/*.ts"] +} diff --git a/permix/tsconfig.react.json b/permix/tsconfig.react.json index 9073ac9e..27ef9c67 100644 --- a/permix/tsconfig.react.json +++ b/permix/tsconfig.react.json @@ -3,7 +3,8 @@ "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "react", - "lib": ["ESNext", "DOM", "DOM.Iterable"] + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": [] }, "include": [ "src/react/*.ts", diff --git a/permix/tsconfig.solid.json b/permix/tsconfig.solid.json index 3e543a59..7a78aaf6 100644 --- a/permix/tsconfig.solid.json +++ b/permix/tsconfig.solid.json @@ -3,7 +3,8 @@ "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "solid-js", - "lib": ["ESNext", "DOM", "DOM.Iterable"] + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": [] }, "include": ["src/solid/*.ts", "src/solid/*.tsx"], "exclude": ["src/react/*.ts", "src/react/*.tsx"] diff --git a/permix/tsdown.config.ts b/permix/tsdown.config.ts index 00e9a0a3..80dfb8ab 100644 --- a/permix/tsdown.config.ts +++ b/permix/tsdown.config.ts @@ -4,6 +4,8 @@ import { defineConfig } from 'tsdown' export default defineConfig({ name: 'permix', + root: './src', + unbundle: true, entry: [ './src/core/index.ts', './src/react/index.ts', @@ -14,14 +16,21 @@ export default defineConfig({ './src/hono/index.ts', './src/node/index.ts', './src/server/index.ts', + './src/astro/index.ts', './src/elysia/index.ts', './src/fastify/index.ts', './src/solid/index.ts', './src/effect/index.ts', './src/drizzle/index.ts', - './src/drizzle/legacy/index.ts', + './src/standard-schema/index.ts', './src/next/index.ts', + './src/nuxt/index.ts', './src/tanstack-start/index.ts', + './src/nest/index.ts', + './src/react-router/index.ts', + './src/extractor/index.ts', + './src/extractor/cli.ts', + './src/next/config.ts', ], dts: { build: true, diff --git a/permix/vitest.config.ts b/permix/vitest.config.ts index 987dc46a..8f175293 100644 --- a/permix/vitest.config.ts +++ b/permix/vitest.config.ts @@ -21,6 +21,11 @@ export default defineConfig({ ], test: { environment: 'happy-dom', - exclude: ['**/node_modules/**', '**/dist/**', '**/.svelte-kit/**'], + exclude: [ + '**/node_modules/**', + '**/dist/**', + '**/.svelte-kit/**', + '**/test/next/**', + ], }, }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 761d130a..35a328e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,10 +4,83 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +catalogs: + default: + '@types/node': + specifier: ^25.9.1 + version: 25.9.1 + '@types/react': + specifier: ^19.2.15 + version: 19.2.15 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3 + '@vitejs/plugin-react': + specifier: ^6.0.2 + version: 6.0.2 + chokidar: + specifier: ^5.0.0 + version: 5.0.0 + esbuild: + specifier: ^0.28.2 + version: 0.28.2 + oxc-parser: + specifier: ^0.147.0 + version: 0.147.0 + oxfmt: + specifier: ^0.64.0 + version: 0.64.0 + oxlint: + specifier: ^1.79.0 + version: 1.80.0 + oxlint-tsgolint: + specifier: ^7.0.2001 + version: 7.0.2001 + react: + specifier: ^19.2.6 + version: 19.2.6 + react-dom: + specifier: ^19.2.6 + version: 19.2.6 + tinyglobby: + specifier: ^0.2.17 + version: 0.2.17 + tsx: + specifier: ^4.22.4 + version: 4.22.4 + turbo: + specifier: 2.10.12 + version: 2.10.12 + typescript: + specifier: 7.0.2 + version: 7.0.2 + ultracite: + specifier: ^7.10.6 + version: 7.10.6 + vite: + specifier: ^8.0.16 + version: 8.0.16 + vitest: + specifier: ^4.1.8 + version: 4.1.8 + typescript-classic: + typescript59: + specifier: npm:typescript@5.9.3 + version: 5.9.3 + typescript6: + specifier: npm:@typescript/typescript6@6.0.2 + version: 6.0.2 + importers: .: devDependencies: + '@commitlint/cli': + specifier: ^20.5.0 + version: 20.5.3(@types/node@25.9.1)(conventional-commits-parser@6.4.0)(typescript@7.0.2) + '@commitlint/config-conventional': + specifier: ^20.5.0 + version: 20.5.3 husky: specifier: ^9.1.7 version: 9.1.7 @@ -15,29 +88,32 @@ importers: specifier: ^9.0.1 version: 9.0.1 oxfmt: - specifier: ^0.64.0 + specifier: 'catalog:' version: 0.64.0(svelte@5.56.0) oxlint: - specifier: ^1.79.0 + specifier: 'catalog:' version: 1.80.0(oxlint-tsgolint@7.0.2001) oxlint-tsgolint: - specifier: ^7.0.2001 + specifier: 'catalog:' version: 7.0.2001 taze: specifier: ^19.14.1 version: 19.14.1 turbo: - specifier: ^2.9.16 - version: 2.9.16 + specifier: 'catalog:' + version: 2.10.12 typescript: - specifier: ^6.0.3 - version: 6.0.3 + specifier: 'catalog:' + version: 7.0.2 ultracite: - specifier: ^7.10.6 + specifier: 'catalog:' version: 7.10.6(oxfmt@0.64.0(svelte@5.56.0))(oxlint@1.80.0(oxlint-tsgolint@7.0.2001)) docs: dependencies: + '@base-ui/react': + specifier: ^1.7.0 + version: 1.7.0(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@remixicon/react': specifier: ^4.9.0 version: 4.9.0(react@19.2.6) @@ -49,22 +125,28 @@ importers: version: 1.167.0(@tanstack/react-router@1.170.15(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@tanstack/router-core@1.171.27)(csstype@3.2.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@tanstack/react-start': specifier: ^1.168.14 - version: 1.168.24(crossws@0.4.5(srvx@0.11.16))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(vite@8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 1.168.24(crossws@0.4.5(srvx@0.11.16))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(supports-color@7.2.0)(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(supports-color@7.2.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@vercel/analytics': specifier: ^2.0.1 - version: 2.0.1(@sveltejs/kit@2.61.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.0)(vite@8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.0)(typescript@6.0.3)(vite@8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(svelte@5.56.0)(vue@3.5.35(typescript@6.0.3)) + version: 2.0.1(@sveltejs/kit@2.61.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.0)(typescript@7.0.2)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(next@16.3.3(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.62.1)(@types/node@25.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(svelte@5.56.0)(vue@3.5.35(typescript@7.0.2)) + flexsearch: + specifier: ^0.8.212 + version: 0.8.212 fumadocs-core: - specifier: ^16.9.3 - version: 16.9.3(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.15(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@1.17.0(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3) + specifier: ^16.15.4 + version: 16.15.4(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@tanstack/react-router@1.170.15(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.15)(flexsearch@0.8.212)(lucide-react@1.34.0(react@19.2.6))(next@16.3.3(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.62.1)(@types/node@25.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(supports-color@7.2.0)(zod@4.4.3) fumadocs-mdx: - specifier: ^15.0.10 - version: 15.0.10(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.15)(fumadocs-core@16.9.3(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.15(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@1.17.0(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(rolldown@1.0.3)(vite@8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + specifier: ^15.4.0 + version: 15.4.0(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.15)(fumadocs-core@16.15.4(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@tanstack/react-router@1.170.15(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.15)(flexsearch@0.8.212)(lucide-react@1.34.0(react@19.2.6))(next@16.3.3(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.62.1)(@types/node@25.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(supports-color@7.2.0)(zod@4.4.3))(next@16.3.3(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.62.1)(@types/node@25.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(rolldown@1.0.3)(supports-color@7.2.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) fumadocs-twoslash: - specifier: ^3.2.0 - version: 3.2.0(1d030e86b7f7b9132e23a7c3ab6b57d0) + specifier: ^3.3.0 + version: 3.3.0(dee5151bb43a354735c1902652f806d2) fumadocs-ui: - specifier: ^16.9.3 - version: 16.9.3(@tailwindcss/oxide@4.3.0)(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(fumadocs-core@16.9.3(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.15(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@1.17.0(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tailwindcss@4.3.0) + specifier: npm:@fumadocs/base-ui@^16.15.4 + version: '@fumadocs/base-ui@16.15.4(@types/mdx@2.0.13)(@types/react@19.2.15)(fumadocs-core@16.15.4(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@tanstack/react-router@1.170.15(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.15)(flexsearch@0.8.212)(lucide-react@1.34.0(react@19.2.6))(next@16.3.3(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.62.1)(@types/node@25.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(supports-color@7.2.0)(zod@4.4.3))(next@16.3.3(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.62.1)(@types/node@25.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tailwindcss@4.3.0)' + mdast-util-from-markdown: + specifier: ^2.0.2 + version: 2.0.3(supports-color@7.2.0) mermaid: specifier: ^11.15.0 version: 11.15.0 @@ -72,27 +154,27 @@ importers: specifier: workspace:* version: link:../permix react: - specifier: ^19.2.6 + specifier: 'catalog:' version: 19.2.6 react-dom: - specifier: ^19.2.6 + specifier: 'catalog:' version: 19.2.6(react@19.2.6) tailwind-merge: specifier: ^3.6.0 version: 3.6.0 vite: - specifier: ^8.0.16 - version: 8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + specifier: 'catalog:' + version: 8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) devDependencies: '@orpc/server': specifier: ^1.14.4 version: 1.14.4(crossws@0.4.5(srvx@0.11.16))(fastify@5.8.5)(ws@8.21.0) '@tailwindcss/vite': specifier: ^4.3.0 - version: 4.3.0(vite@8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.3.0(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@trpc/server': specifier: ^11.17.0 - version: 11.17.0(typescript@6.0.3) + version: 11.17.0(typescript@7.0.2) '@types/express': specifier: ^5.0.6 version: 5.0.6 @@ -100,26 +182,26 @@ importers: specifier: ^2.0.13 version: 2.0.13 '@types/node': - specifier: ^24.10.0 - version: 24.10.0 + specifier: 'catalog:' + version: 25.9.1 '@types/react': - specifier: ^19.2.15 + specifier: 'catalog:' version: 19.2.15 '@types/react-dom': - specifier: ^19.2.3 + specifier: 'catalog:' version: 19.2.3(@types/react@19.2.15) '@vitejs/plugin-react': - specifier: ^6.0.2 - version: 6.0.2(vite@8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + specifier: 'catalog:' + version: 6.0.2(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) drizzle-orm: specifier: ^1.0.0-rc.3 - version: 1.0.0-rc.3(@sinclair/typebox@0.34.49)(@types/pg@8.20.0)(effect@3.21.2)(zod@4.4.3) + version: 1.0.0-rc.3(@sinclair/typebox@0.34.49)(@types/pg@8.20.0)(arktype@2.2.3)(effect@3.21.2)(valibot@1.4.2(typescript@7.0.2))(zod@4.4.3) elysia: specifier: ^1.4.28 - version: 1.4.28(@sinclair/typebox@0.34.49)(exact-mirror@1.0.0)(file-type@22.0.1)(openapi-types@12.1.3)(typescript@6.0.3) + version: 1.4.28(@sinclair/typebox@0.34.49)(exact-mirror@1.0.0)(file-type@22.0.1(supports-color@7.2.0))(openapi-types@12.1.3)(typescript@7.0.2) express: specifier: ^5 - version: 5.2.1 + version: 5.2.1(supports-color@7.2.0) fastify: specifier: ^5.8.5 version: 5.8.5 @@ -128,13 +210,29 @@ importers: version: 4.12.23 nitro: specifier: ^3.0.260522-beta - version: 3.0.260522-beta(chokidar@5.0.0)(drizzle-orm@1.0.0-rc.3(@sinclair/typebox@0.34.49)(@types/pg@8.20.0)(zod@4.4.3))(jiti@2.7.0)(lru-cache@11.5.1)(vite@8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 3.0.260522-beta(chokidar@5.0.0)(drizzle-orm@1.0.0-rc.3(@sinclair/typebox@0.34.49)(@types/pg@8.20.0)(arktype@2.2.3)(valibot@1.4.2(typescript@7.0.2))(zod@4.4.3))(jiti@2.7.0)(lru-cache@11.5.1)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) tailwindcss: specifier: ^4.3.0 version: 4.3.0 typescript: - specifier: ^6.0.3 - version: 6.0.3 + specifier: 'catalog:' + version: 7.0.2 + zod: + specifier: ^4.4.3 + version: 4.4.3 + + examples/astro: + dependencies: + permix: + specifier: workspace:* + version: link:../../permix + devDependencies: + '@types/node': + specifier: ^25.9.1 + version: 25.9.1 + tsx: + specifier: ^4.22.4 + version: 4.22.4 examples/enum-based: dependencies: @@ -142,33 +240,33 @@ importers: specifier: workspace:* version: link:../../permix react: - specifier: ^19.2.0 + specifier: 'catalog:' version: 19.2.6 react-dom: - specifier: ^19.2.6 + specifier: 'catalog:' version: 19.2.6(react@19.2.6) devDependencies: '@types/react': - specifier: ^19.2.15 + specifier: 'catalog:' version: 19.2.15 '@types/react-dom': - specifier: ^19.2.3 + specifier: 'catalog:' version: 19.2.3(@types/react@19.2.15) '@vitejs/plugin-react': - specifier: ^6.0.2 - version: 6.0.2(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + specifier: 'catalog:' + version: 6.0.2(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) typescript: - specifier: ^6.0.3 - version: 6.0.3 + specifier: 'catalog:' + version: 7.0.2 vite: - specifier: ^8.0.16 - version: 8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + specifier: 'catalog:' + version: 8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) examples/express: dependencies: express: specifier: ^5.2.1 - version: 5.2.1 + version: 5.2.1(supports-color@7.2.0) permix: specifier: workspace:* version: link:../../permix @@ -177,31 +275,34 @@ importers: specifier: ^5.0.6 version: 5.0.6 tsx: - specifier: ^4.22.4 + specifier: 'catalog:' version: 4.22.4 + typescript: + specifier: 'catalog:' + version: 7.0.2 examples/express-trpc-react: dependencies: '@trpc/client': specifier: ^11.17.0 - version: 11.17.0(@trpc/server@11.17.0(typescript@6.0.3))(typescript@6.0.3) + version: 11.17.0(@trpc/server@11.17.0(typescript@7.0.2))(typescript@7.0.2) '@trpc/server': specifier: ^11.17.0 - version: 11.17.0(typescript@6.0.3) + version: 11.17.0(typescript@7.0.2) cors: specifier: ^2.8.6 version: 2.8.6 express: specifier: ^5.1.0 - version: 5.2.1 + version: 5.2.1(supports-color@7.2.0) permix: specifier: workspace:* version: link:../../permix react: - specifier: ^19.2.0 + specifier: 'catalog:' version: 19.2.6 react-dom: - specifier: ^19.2.6 + specifier: 'catalog:' version: 19.2.6(react@19.2.6) zod: specifier: ^4.4.3 @@ -217,23 +318,45 @@ importers: specifier: ^8.20.0 version: 8.20.0 '@types/react': - specifier: ^19.2.15 + specifier: 'catalog:' version: 19.2.15 '@types/react-dom': - specifier: ^19.2.3 + specifier: 'catalog:' version: 19.2.3(@types/react@19.2.15) '@vitejs/plugin-react': - specifier: ^6.0.2 - version: 6.0.2(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + specifier: 'catalog:' + version: 6.0.2(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) tsx: - specifier: ^4.22.4 + specifier: 'catalog:' version: 4.22.4 + typescript: + specifier: 'catalog:' + version: 7.0.2 vite: - specifier: ^8.0.16 - version: 8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + specifier: 'catalog:' + version: 8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) vite-tsconfig-paths: specifier: ^6.1.1 - version: 6.1.1(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 6.1.1(supports-color@7.2.0)(typescript@7.0.2)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + + examples/extracted-catalog: + dependencies: + permix: + specifier: workspace:* + version: link:../../permix + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 25.9.1 + tsx: + specifier: 'catalog:' + version: 4.22.4 + typescript: + specifier: 'catalog:' + version: 7.0.2 examples/feature-flags: dependencies: @@ -241,61 +364,105 @@ importers: specifier: workspace:* version: link:../../permix react: - specifier: ^19.2.0 + specifier: 'catalog:' version: 19.2.6 react-dom: - specifier: ^19.2.6 + specifier: 'catalog:' version: 19.2.6(react@19.2.6) devDependencies: '@types/react': - specifier: ^19.2.15 + specifier: 'catalog:' version: 19.2.15 '@types/react-dom': - specifier: ^19.2.3 + specifier: 'catalog:' version: 19.2.3(@types/react@19.2.15) '@vitejs/plugin-react': - specifier: ^6.0.2 - version: 6.0.2(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + specifier: 'catalog:' + version: 6.0.2(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + typescript: + specifier: 'catalog:' + version: 7.0.2 + vite: + specifier: 'catalog:' + version: 8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + + examples/nest: + dependencies: + '@nestjs/common': + specifier: ^11.1.6 + version: 11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0) + '@nestjs/core': + specifier: ^11.1.6 + version: 11.2.3(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/platform-express@11.2.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/platform-express': + specifier: ^11.1.6 + version: 11.2.3(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@11.2.3)(supports-color@7.2.0) + permix: + specifier: workspace:* + version: link:../../permix + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + rxjs: + specifier: ^7.8.2 + version: 7.8.2 + devDependencies: + tsx: + specifier: ^4.22.4 + version: 4.22.4 typescript: specifier: ^6.0.3 version: 6.0.3 - vite: - specifier: ^8.0.16 - version: 8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) examples/next: dependencies: next: - specifier: 16.2.6 - version: 16.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: 16.3.3 + version: 16.3.3(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.62.1)(@types/node@25.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) permix: specifier: workspace:* version: link:../../permix react: - specifier: 19.2.4 - version: 19.2.4 + specifier: 'catalog:' + version: 19.2.6 react-dom: - specifier: 19.2.4 - version: 19.2.4(react@19.2.4) + specifier: 'catalog:' + version: 19.2.6(react@19.2.6) devDependencies: '@tailwindcss/postcss': specifier: ^4.3.0 version: 4.3.0 '@types/node': - specifier: ^25.9.1 + specifier: 'catalog:' version: 25.9.1 '@types/react': - specifier: ^19.2.15 + specifier: 'catalog:' version: 19.2.15 '@types/react-dom': - specifier: ^19.2.3 + specifier: 'catalog:' version: 19.2.3(@types/react@19.2.15) tailwindcss: specifier: ^4.3.0 version: 4.3.0 typescript: - specifier: ^6.0.3 - version: 6.0.3 + specifier: 'catalog:' + version: 7.0.2 + + examples/nuxt: + dependencies: + h3: + specifier: ^1.15.4 + version: 1.15.11 + permix: + specifier: workspace:* + version: link:../../permix + devDependencies: + '@types/node': + specifier: ^25.9.1 + version: 25.9.1 + tsx: + specifier: ^4.22.4 + version: 4.22.4 examples/react: dependencies: @@ -303,29 +470,29 @@ importers: specifier: workspace:* version: link:../../permix react: - specifier: ^19.2.0 + specifier: 'catalog:' version: 19.2.6 react-dom: - specifier: ^19.2.6 + specifier: 'catalog:' version: 19.2.6(react@19.2.6) devDependencies: '@types/react': - specifier: ^19.2.15 + specifier: 'catalog:' version: 19.2.15 '@types/react-dom': - specifier: ^19.2.3 + specifier: 'catalog:' version: 19.2.3(@types/react@19.2.15) '@vitejs/plugin-react': - specifier: ^6.0.2 - version: 6.0.2(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + specifier: 'catalog:' + version: 6.0.2(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) typescript: - specifier: ^6.0.3 - version: 6.0.3 + specifier: 'catalog:' + version: 7.0.2 vite: - specifier: ^8.0.16 - version: 8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + specifier: 'catalog:' + version: 8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) - examples/rebac: + examples/react-router: dependencies: permix: specifier: workspace:* @@ -337,9 +504,22 @@ importers: tsx: specifier: ^4.22.4 version: 4.22.4 + + examples/rebac: + dependencies: + permix: + specifier: workspace:* + version: link:../../permix + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 25.9.1 + tsx: + specifier: 'catalog:' + version: 4.22.4 typescript: - specifier: ^6.0.3 - version: 6.0.3 + specifier: 'catalog:' + version: 7.0.2 examples/role-based: dependencies: @@ -347,27 +527,27 @@ importers: specifier: workspace:* version: link:../../permix react: - specifier: ^19.2.0 + specifier: 'catalog:' version: 19.2.6 react-dom: - specifier: ^19.2.6 + specifier: 'catalog:' version: 19.2.6(react@19.2.6) devDependencies: '@types/react': - specifier: ^19.2.15 + specifier: 'catalog:' version: 19.2.15 '@types/react-dom': - specifier: ^19.2.3 + specifier: 'catalog:' version: 19.2.3(@types/react@19.2.15) '@vitejs/plugin-react': - specifier: ^6.0.2 - version: 6.0.2(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + specifier: 'catalog:' + version: 6.0.2(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) typescript: - specifier: ^6.0.3 - version: 6.0.3 + specifier: 'catalog:' + version: 7.0.2 vite: - specifier: ^8.0.16 - version: 8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + specifier: 'catalog:' + version: 8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) examples/solid: dependencies: @@ -379,14 +559,14 @@ importers: version: 1.9.13 devDependencies: typescript: - specifier: ^6.0.3 - version: 6.0.3 + specifier: 'catalog:' + version: 7.0.2 vite: - specifier: ^8.0.16 - version: 8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + specifier: 'catalog:' + version: 8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) vite-plugin-solid: specifier: ^2.11.12 - version: 2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(supports-color@7.2.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) examples/svelte: dependencies: @@ -396,34 +576,34 @@ importers: devDependencies: '@sveltejs/adapter-auto': specifier: ^7.0.1 - version: 7.0.1(@sveltejs/kit@2.61.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.0)(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))) + version: 7.0.1(@sveltejs/kit@2.61.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.0)(typescript@7.0.2)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))) '@sveltejs/kit': specifier: ^2.61.1 - version: 2.61.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.0)(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 2.61.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.0)(typescript@7.0.2)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@sveltejs/vite-plugin-svelte': specifier: ^7.1.2 - version: 7.1.2(svelte@5.56.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 7.1.2(svelte@5.56.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@types/node': - specifier: ^25.9.1 + specifier: 'catalog:' version: 25.9.1 svelte: specifier: ^5.55.2 version: 5.56.0 svelte-check: specifier: ^4.5.0 - version: 4.5.0(picomatch@4.0.7)(svelte@5.56.0)(typescript@6.0.3) + version: 4.5.0(picomatch@4.0.7)(svelte@5.56.0)(typescript@7.0.2) typescript: - specifier: ^6.0.3 - version: 6.0.3 + specifier: 'catalog:' + version: 7.0.2 vite: - specifier: ^8.0.16 - version: 8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + specifier: 'catalog:' + version: 8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) examples/tanstack-start: dependencies: '@tailwindcss/vite': specifier: ^4.1.18 - version: 4.3.0(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.3.0(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@tanstack/react-devtools': specifier: latest version: 0.10.12(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(csstype@3.2.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(solid-js@1.9.13) @@ -435,13 +615,13 @@ importers: version: 1.167.1(@tanstack/react-router@1.170.32(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@tanstack/router-core@1.171.27)(csstype@3.2.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@tanstack/react-router-ssr-query': specifier: latest - version: 1.167.1(@tanstack/query-core@5.101.0)(@tanstack/react-query@5.101.0(react@19.2.6))(@tanstack/react-router@1.170.32(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@tanstack/router-core@1.171.27)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 1.167.2(@tanstack/query-core@5.102.7)(@tanstack/react-query@5.102.7(react@19.2.6))(@tanstack/react-router@1.170.32(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@tanstack/router-core@1.171.27)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@tanstack/react-start': specifier: latest - version: 1.168.49(crossws@0.4.5(srvx@0.11.16))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 1.168.49(crossws@0.4.5(srvx@0.11.16))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(supports-color@7.2.0)(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(supports-color@7.2.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@tanstack/router-plugin': specifier: ^1.132.0 - version: 1.168.13(@tanstack/react-router@1.170.32(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 1.168.13(@tanstack/react-router@1.170.32(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(supports-color@7.2.0)(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(supports-color@7.2.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) lucide-react: specifier: ^0.545.0 version: 0.545.0(react@19.2.6) @@ -449,10 +629,10 @@ importers: specifier: workspace:* version: link:../../permix react: - specifier: ^19.2.0 + specifier: 'catalog:' version: 19.2.6 react-dom: - specifier: ^19.2.0 + specifier: 'catalog:' version: 19.2.6(react@19.2.6) tailwindcss: specifier: ^4.1.18 @@ -463,10 +643,10 @@ importers: version: 0.5.19(tailwindcss@4.3.0) '@tanstack/devtools-vite': specifier: latest - version: 0.8.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 0.8.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@tanstack/router-cli': specifier: ^1.132.0 - version: 1.167.17 + version: 1.167.17(supports-color@7.2.0) '@testing-library/dom': specifier: ^10.4.1 version: 10.4.1 @@ -474,29 +654,29 @@ importers: specifier: ^16.3.0 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@types/node': - specifier: ^22.10.2 - version: 22.19.20 + specifier: 'catalog:' + version: 25.9.1 '@types/react': - specifier: ^19.2.0 + specifier: 'catalog:' version: 19.2.15 '@types/react-dom': - specifier: ^19.2.0 + specifier: 'catalog:' version: 19.2.3(@types/react@19.2.15) '@vitejs/plugin-react': - specifier: ^6.0.1 - version: 6.0.2(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + specifier: 'catalog:' + version: 6.0.2(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) jsdom: specifier: ^28.1.0 - version: 28.1.0(@noble/hashes@1.8.0) + version: 28.1.0(@noble/hashes@1.8.0)(supports-color@7.2.0) typescript: - specifier: ^6.0.2 - version: 6.0.3 + specifier: 'catalog:' + version: 7.0.2 vite: - specifier: ^8.0.0 - version: 8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + specifier: 'catalog:' + version: 8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) vitest: - specifier: ^4.1.5 - version: 4.1.8(@types/node@22.19.20)(@vitest/coverage-v8@4.1.8)(happy-dom@20.9.0)(jsdom@28.1.0(@noble/hashes@1.8.0))(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + specifier: 'catalog:' + version: 4.1.8(@types/node@25.9.1)(@vitest/coverage-v8@4.1.8)(happy-dom@20.9.0)(jsdom@28.1.0(@noble/hashes@1.8.0)(supports-color@7.2.0))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) examples/vue: dependencies: @@ -505,23 +685,23 @@ importers: version: link:../../permix vue: specifier: ^3.5.35 - version: 3.5.35(typescript@6.0.3) + version: 3.5.35(typescript@7.0.2) devDependencies: '@vitejs/plugin-vue': specifier: ^6.0.7 - version: 6.0.7(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(vue@3.5.35(typescript@6.0.3)) + version: 6.0.7(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(vue@3.5.35(typescript@7.0.2)) '@vue/tsconfig': specifier: ^0.9.1 - version: 0.9.1(typescript@6.0.3)(vue@3.5.35(typescript@6.0.3)) + version: 0.9.1(typescript@7.0.2)(vue@3.5.35(typescript@7.0.2)) typescript: - specifier: ^6.0.3 - version: 6.0.3 + specifier: 'catalog:' + version: 7.0.2 vite: - specifier: ^8.0.16 - version: 8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + specifier: 'catalog:' + version: 8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) vue-tsc: specifier: ^3.3.3 - version: 3.3.3(typescript@6.0.3) + version: 3.3.3(typescript@7.0.2) permix: dependencies: @@ -530,13 +710,13 @@ importers: version: 1.14.4(crossws@0.4.5(srvx@0.11.16))(fastify@5.8.5)(ws@8.21.0) '@trpc/server': specifier: '>=11' - version: 11.17.0(typescript@6.0.3) + version: 11.17.0(typescript@7.0.2) elysia: specifier: '>=1' - version: 1.4.28(@sinclair/typebox@0.34.49)(exact-mirror@1.0.0)(file-type@22.0.1)(openapi-types@12.1.3)(typescript@6.0.3) + version: 1.4.28(@sinclair/typebox@0.34.49)(exact-mirror@1.0.0)(file-type@22.0.1(supports-color@7.2.0))(openapi-types@12.1.3)(typescript@7.0.2) express: specifier: '>=4' - version: 5.2.1 + version: 5.2.1(supports-color@7.2.0) fastify: specifier: '>=5' version: 5.8.5 @@ -544,33 +724,42 @@ importers: specifier: '>=5' version: 5.1.0 hono: - specifier: '>=4' - version: 4.12.23 + specifier: '>=4.12.25' + version: 4.13.5 next: - specifier: '>=14' - version: 16.2.6(@babel/core@7.29.7)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react: - specifier: '>=18' - version: 19.2.6 + specifier: '>=15' + version: 16.2.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.62.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) solid-js: specifier: '>=1' version: 1.9.13 devDependencies: + '@nestjs/common': + specifier: ^11.2.3 + version: 11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0) + '@nestjs/core': + specifier: ^11.2.3 + version: 11.2.3(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/platform-express@11.2.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/platform-express': + specifier: ^11.2.3 + version: 11.2.3(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@11.2.3)(supports-color@7.2.0) + '@nestjs/testing': + specifier: ^11.2.3 + version: 11.2.3(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@11.2.3)(@nestjs/platform-express@11.2.3) '@solidjs/testing-library': specifier: ^0.8.10 version: 0.8.10(solid-js@1.9.13) '@sveltejs/package': specifier: ^2.5.7 - version: 2.5.7(svelte@5.56.0)(typescript@6.0.3) + version: 2.5.7(svelte@5.56.0)(typescript@7.0.2) '@sveltejs/vite-plugin-svelte': specifier: ^7.1.2 - version: 7.1.2(svelte@5.56.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 7.1.2(svelte@5.56.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@tanstack/intent': specifier: ^0.0.42 version: 0.0.42 '@tanstack/react-start': specifier: ^1.168.18 - version: 1.168.18(crossws@0.4.5(srvx@0.11.16))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 1.168.18(crossws@0.4.5(srvx@0.11.16))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(supports-color@7.2.0)(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(supports-color@7.2.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 @@ -579,68 +768,147 @@ importers: version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@testing-library/svelte': specifier: ^5.3.1 - version: 5.3.1(svelte@5.56.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(vitest@4.1.8) + version: 5.3.1(svelte@5.56.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(vitest@4.1.8) '@types/express': specifier: ^5.0.6 version: 5.0.6 '@types/node': - specifier: ^25.9.1 + specifier: 'catalog:' version: 25.9.1 '@types/react': - specifier: ^19.2.15 + specifier: 'catalog:' version: 19.2.15 '@types/supertest': specifier: ^7.2.0 version: 7.2.0 '@vitejs/plugin-react': - specifier: ^6.0.2 - version: 6.0.2(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + specifier: 'catalog:' + version: 6.0.2(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/coverage-v8': specifier: ^4.1.8 version: 4.1.8(vitest@4.1.8) '@vue/test-utils': specifier: ^2.4.10 - version: 2.4.10(@vue/compiler-dom@3.5.35)(@vue/server-renderer@3.5.35(vue@3.5.35(typescript@6.0.3)))(vue@3.5.35(typescript@6.0.3)) + version: 2.4.10(@vue/compiler-dom@3.5.35)(@vue/server-renderer@3.5.35(vue@3.5.35(typescript@7.0.2)))(vue@3.5.35(typescript@7.0.2)) + arktype: + specifier: ^2.2.3 + version: 2.2.3 + chokidar: + specifier: 'catalog:' + version: 5.0.0 drizzle-orm: specifier: 1.0.0-rc.3 - version: 1.0.0-rc.3(@sinclair/typebox@0.34.49)(@types/pg@8.20.0)(effect@3.21.2)(zod@4.4.3) + version: 1.0.0-rc.3(@sinclair/typebox@0.34.49)(@types/pg@8.20.0)(arktype@2.2.3)(effect@3.21.2)(valibot@1.4.2(typescript@7.0.2))(zod@4.4.3) effect: specifier: ^3.21.2 version: 3.21.2 + esbuild: + specifier: 'catalog:' + version: 0.28.2 + h3: + specifier: ^1.15.11 + version: 1.15.11 happy-dom: specifier: ^20.9.0 version: 20.9.0 + oxc-parser: + specifier: 'catalog:' + version: 0.147.0 + react: + specifier: 'catalog:' + version: 19.2.6 react-dom: - specifier: ^19.2.6 + specifier: 'catalog:' version: 19.2.6(react@19.2.6) + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + rxjs: + specifier: ^7.8.2 + version: 7.8.2 supertest: specifier: ^7.2.2 - version: 7.2.2 + version: 7.2.2(supports-color@7.2.0) svelte: specifier: ^5.56.0 version: 5.56.0 svelte-check: specifier: ^4.5.0 - version: 4.5.0(picomatch@4.0.7)(svelte@5.56.0)(typescript@6.0.3) + version: 4.5.0(picomatch@4.0.7)(svelte@5.56.0)(typescript@7.0.2) + tinyglobby: + specifier: 'catalog:' + version: 0.2.17 tsdown: specifier: ^0.22.1 - version: 0.22.1(tsx@4.22.4)(typescript@6.0.3) + version: 0.22.1(tsx@4.22.4)(typescript@7.0.2) typescript: - specifier: ^6.0.3 - version: 6.0.3 + specifier: 'catalog:' + version: 7.0.2 + typescript59: + specifier: catalog:typescript-classic + version: typescript@5.9.3 + typescript6: + specifier: catalog:typescript-classic + version: '@typescript/typescript6@6.0.2' + valibot: + specifier: ^1.4.2 + version: 1.4.2(typescript@7.0.2) vite-plugin-solid: specifier: ^2.11.12 - version: 2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(supports-color@7.2.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) vitest: - specifier: ^4.1.8 - version: 4.1.8(@types/node@25.9.1)(@vitest/coverage-v8@4.1.8)(happy-dom@20.9.0)(jsdom@28.1.0(@noble/hashes@1.8.0))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + specifier: 'catalog:' + version: 4.1.8(@types/node@25.9.1)(@vitest/coverage-v8@4.1.8)(happy-dom@20.9.0)(jsdom@28.1.0(@noble/hashes@1.8.0)(supports-color@7.2.0))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) vue: specifier: ^3.5.17 - version: 3.5.35(typescript@6.0.3) + version: 3.5.35(typescript@7.0.2) zod: specifier: ^4.4.3 version: 4.4.3 + permix/test/next: + dependencies: + permix: + specifier: workspace:* + version: link:../.. + react: + specifier: 'catalog:' + version: 19.2.6 + react-dom: + specifier: 'catalog:' + version: 19.2.6(react@19.2.6) + devDependencies: + '@next/playwright': + specifier: 16.3.3 + version: 16.3.3(@playwright/test@1.62.1) + '@playwright/test': + specifier: ^1.55.1 + version: 1.62.1 + '@types/node': + specifier: 'catalog:' + version: 25.9.1 + '@types/react': + specifier: 'catalog:' + version: 19.2.15 + '@types/react-dom': + specifier: 'catalog:' + version: 19.2.3(@types/react@19.2.15) + next-15: + specifier: npm:next@15.5.24 + version: next@15.5.24(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.62.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + next-16-0: + specifier: npm:next@16.0.11 + version: next@16.0.11(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.62.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + next-16-3: + specifier: npm:next@16.3.3 + version: next@16.3.3(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.62.1)(@types/node@25.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + typescript: + specifier: 'catalog:' + version: 7.0.2 + typescript59: + specifier: catalog:typescript-classic + version: typescript@5.9.3 + packages: '@acemir/cssom@0.9.31': @@ -661,6 +929,12 @@ packages: engines: {node: '>=20.19.0'} hasBin: true + '@ark/schema@0.56.2': + resolution: {integrity: sha512-Qx4D2JFbBWpntiHZaTv7bGG4H/M2rigiknezKg/WVyDSaLdE4YCcWAOoFB7pjjDqHbbV2OqRfntm1nnXvwMexg==} + + '@ark/util@0.56.2': + resolution: {integrity: sha512-9kU2sUE38FZEGG7l3hamYMBieLYEJh2L1mrYD2eXpT+78EnQSV1bhjxJhnxGBMSTbtwpBSDNSK+K60WvaI/DTQ==} + '@asamuzakjp/css-color@5.1.11': resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -791,6 +1065,33 @@ packages: resolution: {integrity: sha512-p7/ABylAYlexb31wtRdIfH9L9A0Z2T/9H6zAqzqndkY2PLkvNNc580wGhp/gGKN4Sp9sQvSkhc6Oga8/O+wTyw==} engines: {node: ^22.18.0 || >=24.11.0} + '@base-ui/react@1.7.0': + resolution: {integrity: sha512-j+8QjX44C32jrXD/qyEAGpFr70FRpGL2CY61mQd9nBPWN737CK0xxD1ceJ055rW4RtdvFDT1e7otzdlfxvsYug==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@date-fns/tz': ^1.2.0 + '@types/react': ^17 || ^18 || ^19 + date-fns: ^4.0.0 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@date-fns/tz': + optional: true + '@types/react': + optional: true + date-fns: + optional: true + + '@base-ui/utils@0.3.2': + resolution: {integrity: sha512-oWy1aq/I2GmYjpl4PhEAhzflF8VPGKgZeq0xAWTbfD5KBWyxcN0ZP2+WHSUm/5Z6lVMBDLReLcoXwSYoRc/zNQ==} + peerDependencies: + '@types/react': ^17 || ^18 || ^19 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + '@bcoe/v8-coverage@1.0.2': resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} @@ -816,6 +1117,87 @@ packages: resolution: {integrity: sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==} engines: {node: '>= 20.12.0'} + '@commitlint/cli@20.5.3': + resolution: {integrity: sha512-OJdL0EXWD5y9LPa0nr/geOwzaS8BsdaybKkcloB0JgsguGxNv2R+hC2FTPqrAcprg35zF33KOQerY0x8W1aesA==} + engines: {node: '>=v18'} + hasBin: true + + '@commitlint/config-conventional@20.5.3': + resolution: {integrity: sha512-j34Qqeaa152chJgz2ysyk0BCpHenJn1lV0Rx0VXf8k3ccQcED+48EZrzMvo9jLmJUyBrrBwvu89I+2er4gW7QQ==} + engines: {node: '>=v18'} + + '@commitlint/config-validator@20.5.0': + resolution: {integrity: sha512-T/Uh6iJUzyx7j35GmHWdIiGRQB+ouZDk0pwAaYq4SXgB54KZhFdJ0vYmxiW6AMYICTIWuyMxDBl1jK74oFp/Gw==} + engines: {node: '>=v18'} + + '@commitlint/ensure@20.5.3': + resolution: {integrity: sha512-4i4AgNvH62owG9MwSiWKrle7HGNpBHHdLnWFIp5fTsHUYe5kRuh15t08L/0pdbbrRk8JKXQxxN4hZQcn+szkrw==} + engines: {node: '>=v18'} + + '@commitlint/execute-rule@20.0.0': + resolution: {integrity: sha512-xyCoOShoPuPL44gVa+5EdZsBVao/pNzpQhkzq3RdtlFdKZtjWcLlUFQHSWBuhk5utKYykeJPSz2i8ABHQA+ZZw==} + engines: {node: '>=v18'} + + '@commitlint/format@20.5.0': + resolution: {integrity: sha512-TI9EwFU/qZWSK7a5qyXMpKPPv3qta7FO4tKW+Wt2al7sgMbLWTsAcDpX1cU8k16TRdsiiet9aOw0zpvRXNJu7Q==} + engines: {node: '>=v18'} + + '@commitlint/is-ignored@20.5.0': + resolution: {integrity: sha512-JWLarAsurHJhPozbuAH6GbP4p/hdOCoqS9zJMfqwswne+/GPs5V0+rrsfOkP68Y8PSLphwtFXV0EzJ+GTXTTGg==} + engines: {node: '>=v18'} + + '@commitlint/lint@20.5.3': + resolution: {integrity: sha512-M7JbWBNr2gXKaPc4i/KipsuW1gkDHpj35KPjWtKy3Z+2AQw5wu1gBi1LIO0uoaij67CqY4K8PxPZSGens4evCw==} + engines: {node: '>=v18'} + + '@commitlint/load@20.5.3': + resolution: {integrity: sha512-1FDZWuKyu98Myb8i7Tp31jPU2rZpOwAdYRyJcy2KoGg7Xk2A+bgHN8smhMaaNSNkmE8fwt53BokywZq8Gv/5XQ==} + engines: {node: '>=v18'} + + '@commitlint/message@20.4.3': + resolution: {integrity: sha512-6akwCYrzcrFcTYz9GyUaWlhisY4lmQ3KvrnabmhoeAV8nRH4dXJAh4+EUQ3uArtxxKQkvxJS78hNX2EU3USgxQ==} + engines: {node: '>=v18'} + + '@commitlint/parse@20.5.0': + resolution: {integrity: sha512-SeKWHBMk7YOTnnEWUhx+d1a9vHsjjuo6Uo1xRfPNfeY4bdYFasCH1dDpAv13Lyn+dDPOels+jP6D2GRZqzc5fA==} + engines: {node: '>=v18'} + + '@commitlint/read@20.5.0': + resolution: {integrity: sha512-JDEIJ2+GnWpK8QqwfmW7O42h0aycJEWNqcdkJnyzLD11nf9dW2dWLTVEa8Wtlo4IZFGLPATjR5neA5QlOvIH1w==} + engines: {node: '>=v18'} + + '@commitlint/resolve-extends@20.5.3': + resolution: {integrity: sha512-+ogW9v/u9JqpvAgTrLra/YTFo0KkjU6iNblF89pPsj4NebNc+DAWctsludwezI8YnsjBmfHpApSwcXprN/f/ew==} + engines: {node: '>=v18'} + + '@commitlint/rules@20.5.3': + resolution: {integrity: sha512-MPlMnb9D3wbszYMp+1hPtuhtPJndRo6I6yfkZVA4+jR8w7Kqp0u2u/Y+gzbaItx5Lltq5rw7FSZQWJMoXUC4NQ==} + engines: {node: '>=v18'} + + '@commitlint/to-lines@20.0.0': + resolution: {integrity: sha512-2l9gmwiCRqZNWgV+pX1X7z4yP0b3ex/86UmUFgoRt672Ez6cAM2lOQeHFRUTuE6sPpi8XBCGnd8Kh3bMoyHwJw==} + engines: {node: '>=v18'} + + '@commitlint/top-level@20.4.3': + resolution: {integrity: sha512-qD9xfP6dFg5jQ3NMrOhG0/w5y3bBUsVGyJvXxdWEwBm8hyx4WOk3kKXw28T5czBYvyeCVJgJJ6aoJZUWDpaacQ==} + engines: {node: '>=v18'} + + '@commitlint/types@20.5.0': + resolution: {integrity: sha512-ZJoS8oSq2CAZEpc/YI9SulLrdiIyXeHb/OGqGrkUP6Q7YV+0ouNAa7GjqRdXeQPncHQIDz/jbCTlHScvYvO/gA==} + engines: {node: '>=v18'} + + '@conventional-changelog/git-client@2.7.0': + resolution: {integrity: sha512-j7A8/LBEQ+3rugMzPXoKYzyUPpw/0CBQCyvtTR7Lmu4olG4yRC/Tfkq79Mr3yuPs0SUitlO2HwGP3gitMJnRFw==} + engines: {node: '>=18'} + peerDependencies: + conventional-commits-filter: ^5.0.0 + conventional-commits-parser: ^6.4.0 + peerDependenciesMeta: + conventional-commits-filter: + optional: true + conventional-commits-parser: + optional: true + '@csstools/color-helpers@6.0.2': resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} engines: {node: '>=20.19.0'} @@ -858,161 +1240,164 @@ packages: '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} - '@esbuild/aix-ppc64@0.28.0': - resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.28.0': - resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.28.0': - resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.28.0': - resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.28.0': - resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.28.0': - resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.28.0': - resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.28.0': - resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.28.0': - resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.28.0': - resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.28.0': - resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.28.0': - resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.28.0': - resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.28.0': - resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.28.0': - resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.28.0': - resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.28.0': - resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.28.0': - resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.28.0': - resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.28.0': - resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.28.0': - resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.28.0': - resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.28.0': - resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.28.0': - resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.28.0': - resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.28.0': - resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -1044,32 +1429,62 @@ packages: '@fastify/proxy-addr@5.1.0': resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} - '@floating-ui/core@1.7.5': - resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} - '@floating-ui/dom@1.7.6': - resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} - '@floating-ui/react-dom@2.1.8': - resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + '@floating-ui/react-dom@2.1.9': + resolution: {integrity: sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==} peerDependencies: react: '>=16.8.0' react-dom: '>=16.8.0' - '@floating-ui/utils@0.2.11': - resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} - '@fumadocs/tailwind@0.0.5': - resolution: {integrity: sha512-ENKPWUDRmriccsrUDE4bDBq3FNr/ms3BP2rWlsAEMV1yP23pcCaan+ceGfeBUsAQjw7sj9Q3R4Kl3g/TCStPzQ==} + '@fuma-translate/react@1.0.2': + resolution: {integrity: sha512-uOiOtBx3nRXR8Nu1GzBf1tApgF1FErDBTHxRIAQeyQdyOoZbrNRN6H4kDCWObY4qyGeGbHydG0DHzgeUgFDMIw==} peerDependencies: - '@tailwindcss/oxide': ^4.0.0 - tailwindcss: ^4.0.0 + '@types/react': '*' + react: ^19.2.0 + react-dom: ^19.2.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@fumadocs/base-ui@16.15.4': + resolution: {integrity: sha512-BcoUPoSbfX/uaUl2NgPn0IKOjqJCkwsWPP6z5uRlKSEdh4zbImwXf3k6+qJ3ULlVaFEY8hqfR2wn+Ya5vPKKEQ==} + peerDependencies: + '@types/mdx': '*' + '@types/react': '*' + fumadocs-core: 16.15.4 + next: 16.x.x + react: ^19.2.0 + react-dom: ^19.2.0 + takumi-js: '*' peerDependenciesMeta: - '@tailwindcss/oxide': + '@types/mdx': + optional: true + '@types/react': optional: true + next: + optional: true + takumi-js: + optional: true + + '@fumadocs/tailwind@0.1.1': + resolution: {integrity: sha512-BnPe52UxSaG8yKlHMKBxXw8h6GpK5qO55ci6+Qd5JnquTvIw6SpfbC1P+qAi82PuPWv1KZAWY8bxRk4+x9ctXw==} + peerDependencies: + tailwindcss: ^4.0.0 + peerDependenciesMeta: tailwindcss: optional: true + '@fumari/image-size@0.1.0': + resolution: {integrity: sha512-x2o9u6P8uKUK15B8XgEoRhR3PgLoLSbQKK6FUCd14JzumEw+e8FXZPelij/dZ4VQVMp4r61VD3DcvSo3aEhLAA==} + '@henrygd/queue@1.2.0': resolution: {integrity: sha512-jW/BLSTpcvExDhqJGxtIPgGr2O0IFF8XUNDwEbfCfhrXT8a4xztQ9Lv6U/vbYzYC0xVWn+3zv6YnLUh3bEFUKA==} @@ -1089,70 +1504,145 @@ packages: cpu: [arm64] os: [darwin] + '@img/sharp-darwin-arm64@0.35.4': + resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + '@img/sharp-darwin-x64@0.34.5': resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [darwin] + '@img/sharp-darwin-x64@0.35.4': + resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.4': + resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} + engines: {node: '>=20.9.0'} + os: [freebsd] + '@img/sharp-libvips-darwin-arm64@1.2.4': resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} cpu: [arm64] os: [darwin] + '@img/sharp-libvips-darwin-arm64@1.3.3': + resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} + cpu: [arm64] + os: [darwin] + '@img/sharp-libvips-darwin-x64@1.2.4': resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} cpu: [x64] os: [darwin] + '@img/sharp-libvips-darwin-x64@1.3.3': + resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} + cpu: [x64] + os: [darwin] + '@img/sharp-libvips-linux-arm64@1.2.4': resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-arm64@1.3.3': + resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-arm@1.3.3': + resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} + cpu: [arm] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-ppc64@1.3.3': + resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-riscv64@1.3.3': + resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-s390x@1.3.3': + resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-x64@1.3.3': + resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} + cpu: [x64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] libc: [musl] + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} + cpu: [arm64] + os: [linux] + libc: [musl] + '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] libc: [musl] + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} + cpu: [x64] + os: [linux] + libc: [musl] + '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -1160,6 +1650,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-arm64@0.35.4': + resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -1167,6 +1664,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-arm@0.35.4': + resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -1174,6 +1678,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-ppc64@0.35.4': + resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -1181,6 +1692,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-riscv64@0.35.4': + resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -1188,6 +1706,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-s390x@0.35.4': + resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -1195,6 +1720,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-x64@0.35.4': + resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -1202,6 +1734,13 @@ packages: os: [linux] libc: [musl] + '@img/sharp-linuxmusl-arm64@0.35.4': + resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -1209,29 +1748,63 @@ packages: os: [linux] libc: [musl] + '@img/sharp-linuxmusl-x64@0.35.4': + resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [wasm32] + '@img/sharp-wasm32@0.35.4': + resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.4': + resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + '@img/sharp-win32-arm64@0.34.5': resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [win32] + '@img/sharp-win32-arm64@0.35.4': + resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + '@img/sharp-win32-ia32@0.34.5': resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ia32] os: [win32] + '@img/sharp-win32-ia32@0.35.4': + resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + '@img/sharp-win32-x64@0.34.5': resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [win32] + '@img/sharp-win32-x64@0.35.4': + resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -1252,6 +1825,10 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@lukeed/csprng@1.1.0': + resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} + engines: {node: '>=8'} + '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} @@ -1273,21 +1850,138 @@ packages: '@neodrag/core': 3.0.0-next.11 solid-js: ^1.0.0 + '@nestjs/common@11.2.3': + resolution: {integrity: sha512-obdauJXHfthhepbV+LpGe88OeBlR/Kw9lwjLo0Utzc//agoLXYb9DUGhPQWtm81IpBWMv+19eiwcve9MsBZwXA==} + peerDependencies: + class-transformer: '>=0.4.1' + class-validator: '>=0.13.2' + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/core@11.2.3': + resolution: {integrity: sha512-vkA9/Ja0Z3hvqXErSa+HaxrfF+cNXthNFi8VPNEKVli4rMd009yExAl0gLmko/Kf8peDXr72u1RN+j9Da2ukHg==} + engines: {node: '>= 20'} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/microservices': ^11.0.0 + '@nestjs/platform-express': ^11.0.0 + '@nestjs/websockets': ^11.0.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + '@nestjs/microservices': + optional: true + '@nestjs/platform-express': + optional: true + '@nestjs/websockets': + optional: true + + '@nestjs/platform-express@11.2.3': + resolution: {integrity: sha512-YFQvRXT2de1qNL9LJPUBQ31+RsfI4cJ+sbpU9ENM/hDCgoHSEhm7oxUuGGKmhTZBNZEYm8mDYdfoTFmAH1LIJg==} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/core': ^11.0.0 + + '@nestjs/testing@11.2.3': + resolution: {integrity: sha512-7ANDWlkm8Xw4CYIhCNZhtBzANsQUKqjteA2yx/6sjqGyWhekeBKz8wgCJykm0vo+ltrg6U34dZlm2NgiRcNHPQ==} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/core': ^11.0.0 + '@nestjs/microservices': ^11.0.0 + '@nestjs/platform-express': ^11.0.0 + peerDependenciesMeta: + '@nestjs/microservices': + optional: true + '@nestjs/platform-express': + optional: true + + '@next/env@15.5.24': + resolution: {integrity: sha512-mBDF7T0XKZjs9SpUAl0buizVO+O02ULjOvWX8o/AZo/5AGw/UAS1Zzcylmd4pqbftzmKQi+L/nB4jgBYKEAl5Q==} + + '@next/env@16.0.11': + resolution: {integrity: sha512-hULMheQaOhFK1vAoFPigXca42LguwyLILtJKPRzpY1d+og6jk0YNAQVwLGNYYhWEMd2zj4gcIWSf1yC5PffqqA==} + '@next/env@16.2.6': resolution: {integrity: sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==} + '@next/env@16.3.3': + resolution: {integrity: sha512-U2eYQRwXj+dsqxV79zFqExDdatnNY/ZWc2nsJU1p/OgT7fd3dXwlF6OjYaFQCfMoeTA19PWq+wVmYgimVA+V+g==} + + '@next/playwright@16.3.3': + resolution: {integrity: sha512-Uha5vKciXER99u9J9ePBW6FQJxMklZp9K2r+xzW/KBvaS2ITvOsP/6BMyQBv2FJbitW5TQv+KNv72aqtnsikuw==} + peerDependencies: + '@playwright/test': '>=1.0.0' + peerDependenciesMeta: + '@playwright/test': + optional: true + + '@next/swc-darwin-arm64@15.5.24': + resolution: {integrity: sha512-AGdNLvxZNY6eR2iSnV+6wUa8CiHTMr4F7g3uHH7fT4ICIJBE00R9u4tzN/Vuwsw0cOi8MTD2HJcTCb6siMH88Q==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-arm64@16.0.11': + resolution: {integrity: sha512-3G7Rx6m6tgLqkc3Ce3QY/Yrsx7nJF4ithdHfx70Jmzel8m2xpjnGRC+oB4UcCHvQwN0ZP5YsLJakwx/M0vWbSQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + '@next/swc-darwin-arm64@16.2.6': resolution: {integrity: sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] + '@next/swc-darwin-arm64@16.3.3': + resolution: {integrity: sha512-8Hiv32QJPwdV6KYJ8meR9SBA061tQqnIKTJDocvOXlEQqib0xMFpzArosuffFUUc0sslbh7QQ8a3Yey1QV8EIw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@15.5.24': + resolution: {integrity: sha512-9HrQajBMmGcrrrvDfRimiCrbAPh3E6uHJmwBovYr6Yrmi9p9PZqI876BrXX280wICh3o2XwUlp4blkB0NNBqFg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-darwin-x64@16.0.11': + resolution: {integrity: sha512-poUTsYKRwuG+eApDngouEiN6AGcAMq8TAQYP8Nou7iMS7x6+q3dFhhyhgodIzTF9acsEINl4cIzMaM9XJor8kw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + '@next/swc-darwin-x64@16.2.6': resolution: {integrity: sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] + '@next/swc-darwin-x64@16.3.3': + resolution: {integrity: sha512-A1lgKgwVchRYmSe467zdwhxT9040dd8lH+o65sL5Jet8fjB4kegw/rDyPIpYVRb6jAqwXFOJpjIXJLxQKLiE3A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@15.5.24': + resolution: {integrity: sha512-rl9LSfE75si0WT3cDgdUC1XYCKS+TgxC+/IjitmeycrAG18X/plIP1/vy8dd/HPycYcIvE688PD7FuvEAiEAew==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-arm64-gnu@16.0.11': + resolution: {integrity: sha512-Q9shvB+eLNrK/n8w+/ZTWSzbEIzJ56mP83ZVaqmHay6/Ulcn6THEId4gxfYCXmSwEG/xPAtv58FBWeZkp36XUA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@next/swc-linux-arm64-gnu@16.2.6': resolution: {integrity: sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==} engines: {node: '>= 10'} @@ -1295,6 +1989,27 @@ packages: os: [linux] libc: [glibc] + '@next/swc-linux-arm64-gnu@16.3.3': + resolution: {integrity: sha512-bf0FIssMFueU2dm7vQEWWxk0c8UjKTdW0yzuh0sQsD8pf1+KCLDdaqhYZNMYGmXwEOiHAUzgBKudovIlcvvBjg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-arm64-musl@15.5.24': + resolution: {integrity: sha512-TlNAnpsjxSF3aAUtqnfmtXXf8m9sIDBlmF3c7bTAlnshUYu2U0OxN2uf5d0gcFwqHVEdivJNBcCaqNOwPGNimw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@next/swc-linux-arm64-musl@16.0.11': + resolution: {integrity: sha512-rq+d/a0FZHVPEh3zismoQgfVkSIEzlTbNhD4Z8bToLMszUlggAh1D1syhJ4MHkYzXRszhjS2emy0PYXz7Uwttw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + '@next/swc-linux-arm64-musl@16.2.6': resolution: {integrity: sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==} engines: {node: '>= 10'} @@ -1302,6 +2017,27 @@ packages: os: [linux] libc: [musl] + '@next/swc-linux-arm64-musl@16.3.3': + resolution: {integrity: sha512-W7viwCk9JY/cAkdz/A273rd5bb3RgT/IHwR7Upv90tunjBWNtAAhGhoecHh+teRNRSinuAFmE+l7fwZ4YKkrXg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@next/swc-linux-x64-gnu@15.5.24': + resolution: {integrity: sha512-7dwtlhr0SLndqTG1z9ncRkbJswDZiKWlxzFyXDvJ2RDZRDRHp8zyMJ4D9UH/FgnQeXxxB6gZy2pMcIUoNKQ4pA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-x64-gnu@16.0.11': + resolution: {integrity: sha512-82Wroterii1p15O+ZF/DDsHPuxKptR1JGK+obgbAk13vrc3B/fTJ2qOOmdeoMwAQ15gb/9mN4LQl9+IzFje76Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + '@next/swc-linux-x64-gnu@16.2.6': resolution: {integrity: sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==} engines: {node: '>= 10'} @@ -1309,6 +2045,27 @@ packages: os: [linux] libc: [glibc] + '@next/swc-linux-x64-gnu@16.3.3': + resolution: {integrity: sha512-0W46zw1N3ODpI6n0GeivHvvob1pooozgZVqy65k0mh4/7vr+FbY9+WpHzNVXjHipJf/A3FDheBG19H1s5A25rA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-x64-musl@15.5.24': + resolution: {integrity: sha512-kGZxM+WhkYs0276lFrMkj7PRtXT3Btp6cwvfSO/cCVLxJJttB5Ccnl2niaCgUja8HgSbEVnMHpg3FJWoOJ9e/g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@next/swc-linux-x64-musl@16.0.11': + resolution: {integrity: sha512-YK9RoeZuHWBd+wHi5/7VLp6P5ZOldAjQfBjjtzcR4f14FNmwT0a3ozMMlG2txDxh53krAd5yOO601RbJxH0gCQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + '@next/swc-linux-x64-musl@16.2.6': resolution: {integrity: sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==} engines: {node: '>= 10'} @@ -1316,18 +2073,61 @@ packages: os: [linux] libc: [musl] + '@next/swc-linux-x64-musl@16.3.3': + resolution: {integrity: sha512-H4mBso8ZTMBPtdT0PN0pBx2ayTvQuTuvS6qT13d77yVFJXAPCxkyIhLTmdMaGTJs0krQYI/qpzdHijCeihXhbg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@next/swc-win32-arm64-msvc@15.5.24': + resolution: {integrity: sha512-jBDDkZ/qKAqkWivWDMkJSXUzbzV0QKRBKJjEHUAvSB97Hzw7NLzJ6yV56Lts/wjir7s4P31GYgpbS6ZL+hasAA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-arm64-msvc@16.0.11': + resolution: {integrity: sha512-pcDMpSckekV8xj2SSKO8PaqaJhrmDx84zUNip0kOWsT/ERhhDpnWkr6KXMqRXVp2y5CW9pp4LwOFdtpt3rhRgw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + '@next/swc-win32-arm64-msvc@16.2.6': resolution: {integrity: sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] + '@next/swc-win32-arm64-msvc@16.3.3': + resolution: {integrity: sha512-cTMUJpcEGmeywofCUfhR+rSsoE33+rVPnPEYNTNdLNlsOeEg/vktOsKUSTb28vUGqD2jkm4Zaskcwn7OCI6FQg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@15.5.24': + resolution: {integrity: sha512-JqtwjvvorjacQ0spgjmUJoxySoYgPwdT1sFdQ0/zmW4iMlP2hjYlCoJIyS7o6Epb4Fug8eco3HXFoBDUCDeH7Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@next/swc-win32-x64-msvc@16.0.11': + resolution: {integrity: sha512-Zzo9NLLRzBSHw9zOGpER/gdc5rofZHLjR2OIUIfoBaN2Oo5zWRl43IF5rMSX2LX7MPLTx4Ww8+5lNHAhXgitnA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + '@next/swc-win32-x64-msvc@16.2.6': resolution: {integrity: sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==} engines: {node: '>= 10'} cpu: [x64] os: [win32] + '@next/swc-win32-x64-msvc@16.3.3': + resolution: {integrity: sha512-2VR4cTBzHXaBjnGsuH6GyJjENzQOmHeAh11uY1iUhjm3j5dEUrVJuUj+VL78jaGi/Dik8xS76zEj18BsFhlVZQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + '@noble/hashes@1.8.0': resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} @@ -1363,10 +2163,6 @@ packages: resolution: {integrity: sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA==} engines: {node: '>=20.0'} - '@orama/orama@3.1.18': - resolution: {integrity: sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA==} - engines: {node: '>= 20.0.0'} - '@orpc/client@1.14.4': resolution: {integrity: sha512-i6Z9FikIm9Qz3Br10vk8/cllgjdYdlRKK2OV3x2/CdOBRr+B68tboNdpH3eeb1kv8/Zd6ZsXcWaDfrANdj2GZQ==} @@ -1424,42 +2220,84 @@ packages: cpu: [arm] os: [android] + '@oxc-parser/binding-android-arm-eabi@0.147.0': + resolution: {integrity: sha512-fOtoGvIoirkvxQVw9J1WJPxz571XPgLsPf9uhRD+PJteUnvrJHMDmK9pw2yZEGGyismtRoEsp+JcXUdF/JDMDw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + '@oxc-parser/binding-android-arm64@0.120.0': resolution: {integrity: sha512-SEf80EHdhlbjZEgzeWm0ZA/br4GKMenDW3QB/gtyeTV1gStvvZeFi40ioHDZvds2m4Z9J1bUAUL8yn1/+A6iGg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] + '@oxc-parser/binding-android-arm64@0.147.0': + resolution: {integrity: sha512-emjQHOYJaomo4ykaXQ1EItunr/I94Nk01oqBmU4dSkKSTupIDx6OysVDf2e8Eytm77rb+4ZxzgElyWP7rcEX7A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + '@oxc-parser/binding-darwin-arm64@0.120.0': resolution: {integrity: sha512-xVrrbCai8R8CUIBu3CjryutQnEYhZqs1maIqDvtUCFZb8vY33H7uh9mHpL3a0JBIKoBUKjPH8+rzyAeXnS2d6A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] + '@oxc-parser/binding-darwin-arm64@0.147.0': + resolution: {integrity: sha512-kXvBPJL7RmDPJ2mze/vXPPVQimCDtFr9OFLjf7dyhV5Dx64cgcXh9KKrA1sMWvCObvJll9CZZUO0FBlFwD0l6A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + '@oxc-parser/binding-darwin-x64@0.120.0': resolution: {integrity: sha512-xyHBbnJ6mydnQUH7MAcafOkkrNzQC6T+LXgDH/3InEq2BWl/g424IMRiJVSpVqGjB+p2bd0h0WRR8iIwzjU7rw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] + '@oxc-parser/binding-darwin-x64@0.147.0': + resolution: {integrity: sha512-mgFF8pLU6R64LbT27lSrtVRspVC/3IcZ0qyIikzmi78Y3Ik2OPnlAHHI0UEBRcC3qmNgtjaef7zkFt7/uPxIcw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + '@oxc-parser/binding-freebsd-x64@0.120.0': resolution: {integrity: sha512-UMnVRllquXUYTeNfFKmxTTEdZ/ix1nLl0ducDzMSREoWYGVIHnOOxoKMWlCOvRr9Wk/HZqo2rh1jeumbPGPV9A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] + '@oxc-parser/binding-freebsd-x64@0.147.0': + resolution: {integrity: sha512-v38aiF11qufOTBcCAKL4skgQf0zJ4NEvRlivq7B5kHrlyvjCLjvNrMtNWDTz1SDUL6/xVsJRmLDxv2e+Cp4oWw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + '@oxc-parser/binding-linux-arm-gnueabihf@0.120.0': resolution: {integrity: sha512-tkvn2CQ7QdcsMnpfiX3fd3wA3EFsWKYlcQzq9cFw/xc89Al7W6Y4O0FgLVkVQpo0Tnq/qtE1XfkJOnRRA9S/NA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] + '@oxc-parser/binding-linux-arm-gnueabihf@0.147.0': + resolution: {integrity: sha512-AeIiBbwUaP0H1+4/qGW9l5qHecS/+XA5iMuieVcGb1T+tyc2dVGspFW13BWk/XrLsiGP/CiDJTJqAPLLCzZHkw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@oxc-parser/binding-linux-arm-musleabihf@0.120.0': resolution: {integrity: sha512-WN5y135Ic42gQDk9grbwY9++fDhqf8knN6fnP+0WALlAUh4odY/BDK1nfTJRSfpJD9P3r1BwU0m3pW2DU89whQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] + '@oxc-parser/binding-linux-arm-musleabihf@0.147.0': + resolution: {integrity: sha512-/41MKPW4RgPY4DJco0NCF0RYX3IMZaVlRNMNzvhaxRavc7tN3Txm+qllZbh0aMRs0VHdgUlbI8TcAOiTai4TKg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@oxc-parser/binding-linux-arm64-gnu@0.120.0': resolution: {integrity: sha512-1GgQBCcXvFMw99EPdMy+4NZ3aYyXsxjf9kbUUg8HuAy3ZBXzOry5KfFEzT9nqmgZI1cuetvApkiJBZLAPo8uaw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1467,6 +2305,13 @@ packages: os: [linux] libc: [glibc] + '@oxc-parser/binding-linux-arm64-gnu@0.147.0': + resolution: {integrity: sha512-bmpw/RPhVXgZbtb3xBDuwW5s8+LvZYdqcDSX/sP2ltL77aTio3DP/B5ZTwwgoJ6Mr9vJs4RrmgEKW9XkLNUU1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@oxc-parser/binding-linux-arm64-musl@0.120.0': resolution: {integrity: sha512-gmMQ70gsPdDBgpcErvJEoWNBr7bJooSLlvOBVBSGfOzlP5NvJ3bFvnUeZZ9d+dPrqSngtonf7nyzWUTUj/U+lw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1474,6 +2319,13 @@ packages: os: [linux] libc: [musl] + '@oxc-parser/binding-linux-arm64-musl@0.147.0': + resolution: {integrity: sha512-gd7VX/FDVOw6mjQcu45iIcp4QkgybgJwh3a0OFG2NxmPCj628mQWD96QGu1kK8+mZF9qK4b/gIEyC63vQoB7+Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + '@oxc-parser/binding-linux-ppc64-gnu@0.120.0': resolution: {integrity: sha512-T/kZuU0ajop0xhzVMwH5r3srC9Nqup5HaIo+3uFjIN5uPxa0LvSxC1ZqP4aQGJVW5G0z8/nCkjIfSMS91P/wzw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1481,6 +2333,13 @@ packages: os: [linux] libc: [glibc] + '@oxc-parser/binding-linux-ppc64-gnu@0.147.0': + resolution: {integrity: sha512-HnAzcfki7dSUNHf510Q2NmbJlz8Ys7rn8l9l588Pkx0tYe1BHLZnmELIgqizJ4WPhHGSwN8Ce+B/menVxS3odA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@oxc-parser/binding-linux-riscv64-gnu@0.120.0': resolution: {integrity: sha512-vn21KXLAXzaI3N5CZWlBr1iWeXLl9QFIMor7S1hUjUGTeUuWCoE6JZB040/ZNDwf+JXPX8Ao9KbmJq9FMC2iGw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1488,6 +2347,13 @@ packages: os: [linux] libc: [glibc] + '@oxc-parser/binding-linux-riscv64-gnu@0.147.0': + resolution: {integrity: sha512-qlkOL6wT44U+fT5s/+sR6Shx0OdwvQF83JyIPZUxG/ovqZF5/7atOtjH+JPZ5/7ATQLbFBBSmghcy/+2NVB/ew==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@oxc-parser/binding-linux-riscv64-musl@0.120.0': resolution: {integrity: sha512-SUbUxlar007LTGmSLGIC5x/WJvwhdX+PwNzFJ9f/nOzZOrCFbOT4ikt7pJIRg1tXVsEfzk5mWpGO1NFiSs4PIw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1495,6 +2361,13 @@ packages: os: [linux] libc: [musl] + '@oxc-parser/binding-linux-riscv64-musl@0.147.0': + resolution: {integrity: sha512-DlefD7L7sMXs/3hIBH23Egk0phj8kG0SA81dVGOQ3S1ekjOlmTLH2E+F2Thwfh1slKx6aH+lNc5fQYAw0GU7/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + '@oxc-parser/binding-linux-s390x-gnu@0.120.0': resolution: {integrity: sha512-hYiPJTxyfJY2+lMBFk3p2bo0R9GN+TtpPFlRqVchL1qvLG+pznstramHNvJlw9AjaoRUHwp9IKR7UZQnRPGjgQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1502,6 +2375,13 @@ packages: os: [linux] libc: [glibc] + '@oxc-parser/binding-linux-s390x-gnu@0.147.0': + resolution: {integrity: sha512-Xqpagk/031IvZ4svrk2FF01YEqM/iN3MJV3SVZadKg/CsGlDCGoREqKHXYnoV5+8SfGe/m6RM1szXFLusTu/Uw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@oxc-parser/binding-linux-x64-gnu@0.120.0': resolution: {integrity: sha512-q+5jSVZkprJCIy3dzJpApat0InJaoxQLsJuD6DkX8hrUS61z2lHQ1Fe9L2+TYbKHXCLWbL0zXe7ovkIdopBGMQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1509,6 +2389,13 @@ packages: os: [linux] libc: [glibc] + '@oxc-parser/binding-linux-x64-gnu@0.147.0': + resolution: {integrity: sha512-QioQOeUbI4ATUr0S2z88uA3Cds2R3Mm5Ge7U8XNYtlTb2GJF3rWlcj70z0AJhhOlbdm0YgVjqPBldUNbFylDIg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + '@oxc-parser/binding-linux-x64-musl@0.120.0': resolution: {integrity: sha512-D9QDDZNnH24e7X4ftSa6ar/2hCavETfW3uk0zgcMIrZNy459O5deTbWrjGzZiVrSWigGtlQwzs2McBP0QsfV1w==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1516,12 +2403,25 @@ packages: os: [linux] libc: [musl] + '@oxc-parser/binding-linux-x64-musl@0.147.0': + resolution: {integrity: sha512-NXy1tv/OdC+pPTwf9RiCZWPK53V/Xq/2cjSnjOSyKopajdaDqMIkgtDY+jXZemp2e8px5FeWfY2L2LwhKZQovg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + '@oxc-parser/binding-openharmony-arm64@0.120.0': resolution: {integrity: sha512-TBU8ZwOUWAOUWVfmI16CYWbvh4uQb9zHnGBHsw5Cp2JUVG044OIY1CSHODLifqzQIMTXvDvLzcL89GGdUIqNrA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] + '@oxc-parser/binding-openharmony-arm64@0.147.0': + resolution: {integrity: sha512-GpGWZ6oKz4bjCWW9Mz5pCaGPyk2Aaze6zEoaslIQqpSLtpx5pXj/ap5gUNb5Jn2LIbqWyjkGLX9yv3NMuNcBVQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + '@oxc-parser/binding-wasm32-wasi@0.120.0': resolution: {integrity: sha512-WG/FOZgDJCpJnuF3ToG/K28rcOmSY7FmFmfBKYb2fmLyhDzPpUldFGV7/Fz4ru0Iz/v4KPmf8xVgO8N3lO4KHA==} engines: {node: '>=14.0.0'} @@ -1533,24 +2433,45 @@ packages: cpu: [arm64] os: [win32] + '@oxc-parser/binding-win32-arm64-msvc@0.147.0': + resolution: {integrity: sha512-a8mlt7CC8z7LUdCfaxhff4kCd+vSjE+NEFL0cxA8ukfuSnvAto/pWTjytW4BuVLnQGcCVdHJwcRKOfs++H+tjw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + '@oxc-parser/binding-win32-ia32-msvc@0.120.0': resolution: {integrity: sha512-L7vfLzbOXsjBXV0rv/6Y3Jd9BRjPeCivINZAqrSyAOZN3moCopDN+Psq9ZrGNZtJzP8946MtlRFZ0Als0wBCOw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] + '@oxc-parser/binding-win32-ia32-msvc@0.147.0': + resolution: {integrity: sha512-M5ViVDBcFLnl2632AuuWuP35zEL5oikK1jTx8r3+902VEDeSNHxFZAB6RZyfZ7MU6Oi5QTOG8MmMkIeHsudSaw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + '@oxc-parser/binding-win32-x64-msvc@0.120.0': resolution: {integrity: sha512-ys+upfqNtSu58huAhJMBKl3XCkGzyVFBlMlGPzHeFKgpFF/OdgNs1MMf8oaJIbgMH8ZxgGF7qfue39eJohmKIg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] + '@oxc-parser/binding-win32-x64-msvc@0.147.0': + resolution: {integrity: sha512-DUaE13OwnUSlHpLZNcC/nuT10ivlWqc5EZgsfgXuAmWYw0r3nDxGeLD1zlGwYwIgVk1/ZMAxoXpV+05stvbHaA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@oxc-project/types@0.120.0': resolution: {integrity: sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==} '@oxc-project/types@0.133.0': resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + '@oxc-project/types@0.147.0': + resolution: {integrity: sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==} + '@oxfmt/binding-android-arm-eabi@0.64.0': resolution: {integrity: sha512-o6uzh/jTOQeAY5TdkAeXdqv7MBRcPxiRA08zrcBtkKj5cSu/FMu0Hl7Q6Fi1KCKyCWZ6lJVjBzdsJvsKltUsGQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1835,377 +2756,17 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/test@1.62.1': + resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + engines: {node: '>=20'} + hasBin: true + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} - '@radix-ui/number@1.1.1': - resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} - - '@radix-ui/primitive@1.1.3': - resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} - - '@radix-ui/react-accordion@1.2.12': - resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-arrow@1.1.7': - resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-collapsible@1.1.12': - resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-collection@1.1.7': - resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-compose-refs@1.1.2': - resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-context@1.1.2': - resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-dialog@1.1.15': - resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-direction@1.1.1': - resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-dismissable-layer@1.1.11': - resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-focus-guards@1.1.3': - resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-focus-scope@1.1.7': - resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-id@1.1.1': - resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-navigation-menu@1.2.14': - resolution: {integrity: sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-popover@1.1.15': - resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-popper@1.2.8': - resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-portal@1.1.9': - resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-presence@1.1.5': - resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-primitive@2.1.3': - resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-roving-focus@1.1.11': - resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-scroll-area@1.2.10': - resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-slot@1.2.3': - resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-slot@1.2.4': - resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-tabs@1.1.13': - resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-use-callback-ref@1.1.1': - resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-controllable-state@1.2.2': - resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-effect-event@0.0.2': - resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-escape-keydown@1.1.1': - resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-layout-effect@1.1.1': - resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-previous@1.1.1': - resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-rect@1.1.1': - resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-size@1.1.1': - resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-visually-hidden@1.2.3': - resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/rect@1.1.1': - resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} - '@remixicon/react@4.9.0': resolution: {integrity: sha512-5/jLDD4DtKxH2B4QVXTobvV1C2uL8ab9D5yAYNtFt+w80O0Ys1xFOrspqROL3fjrZi+7ElFUWE37hBfaAl6U+Q==} peerDependencies: @@ -2312,43 +2873,51 @@ packages: '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} - '@shikijs/core@4.1.0': - resolution: {integrity: sha512-jLJtSJeuFffqX6/inRE1zqU5aFv2hrszvYgq3OjbAgFRZiWv7abKMDdQzYxuSDfmUPQozZvI/kuy6VMTvnvqTQ==} + '@shikijs/core@4.4.3': + resolution: {integrity: sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==} engines: {node: '>=20'} - '@shikijs/engine-javascript@4.1.0': - resolution: {integrity: sha512-YquhawCUgaBfhsS72e2Y/dI59gCBNPHu3fEO/tvLaXrTssxZrY5ddjtNLTwndrMgPo8b3IscE+xoICDzpTmlFQ==} + '@shikijs/engine-javascript@4.4.3': + resolution: {integrity: sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ==} engines: {node: '>=20'} - '@shikijs/engine-oniguruma@4.1.0': - resolution: {integrity: sha512-axLpjVs45YBvvINa+dJF+NPW+KtFkNXsFr4SDw2BMj9GdeMnGxVB9PQb2xXlJYovslt/nz6giedAyOANkfc7hg==} + '@shikijs/engine-oniguruma@4.4.3': + resolution: {integrity: sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w==} engines: {node: '>=20'} - '@shikijs/langs@4.1.0': - resolution: {integrity: sha512-nwOMruEkbgdZfQ/b8CgpNBVOpvG1k0N5tbmgiFeqsan401+x3ILqlzZJowSla4Agmq4hG2Uf2wh5jLTEhR8VSg==} + '@shikijs/langs@4.4.3': + resolution: {integrity: sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A==} engines: {node: '>=20'} - '@shikijs/primitive@4.1.0': - resolution: {integrity: sha512-zx2/2Uwj2q9X3KSyYREEhXO23xBw5WUhP4orK2lE4r+t9JGITmEe0JH+wPmJhqHpOT2bRRs6lAL945+LDvOAGw==} + '@shikijs/primitive@4.4.3': + resolution: {integrity: sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ==} engines: {node: '>=20'} - '@shikijs/themes@4.1.0': - resolution: {integrity: sha512-emCcTnUM7yO2wltYbaxm+yLvcCI4+h8XBKc4KmJ7EZUXoSGjcCHifkI//R4OFit9ewpg7H2/9tjOuXrT2v/Knw==} + '@shikijs/themes@4.4.3': + resolution: {integrity: sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw==} engines: {node: '>=20'} - '@shikijs/twoslash@4.1.0': - resolution: {integrity: sha512-XD7d3LqLXOaL6PbFKGwtdnfcyBdOfKWfxb1+c4MVknVTemwnMQxqj1wYhhWBLRdk8H3Fp9pyf/FILfy2Nzeg8g==} + '@shikijs/twoslash@4.4.3': + resolution: {integrity: sha512-m7HNzunEIHRk1jCya3ngGsO3+8pYxrPIIxtdJewg/W8ceW/+m/mSsm4jM3L9DvYYNa8Rvbu7Dabt3BOpCclz8Q==} engines: {node: '>=20'} peerDependencies: typescript: '>=5.5.0' - '@shikijs/types@4.1.0': - resolution: {integrity: sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA==} + '@shikijs/types@4.4.3': + resolution: {integrity: sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==} engines: {node: '>=20'} '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@simple-libs/child-process-utils@1.0.2': + resolution: {integrity: sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw==} + engines: {node: '>=18'} + + '@simple-libs/stream-utils@1.2.0': + resolution: {integrity: sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==} + engines: {node: '>=18'} + '@sinclair/typebox@0.34.49': resolution: {integrity: sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==} @@ -2442,6 +3011,9 @@ packages: '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} + '@tailwindcss/node@4.3.0': resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==} @@ -2593,8 +3165,8 @@ packages: resolution: {integrity: sha512-jRjArJ8wcMmi49tByK6KCrJxPp8EqFuFH+ltDG6HTPizTrBCAXkJB3dTtTXurLJh67YsPLzU0wLPdOtWYDuGSw==} hasBin: true - '@tanstack/query-core@5.101.0': - resolution: {integrity: sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==} + '@tanstack/query-core@5.102.7': + resolution: {integrity: sha512-4TH8KQrCLoNs9Zvl1LW1iaZ9cZx3dGTgfZ1BnLrcRxMNnX/toe/dGbfvF/DkvbzRyfLfpMKZkembz3o0rnugtw==} '@tanstack/react-devtools@0.10.12': resolution: {integrity: sha512-dgoz7TFm97Izo/D34z91PD0h+ufk+eBmoN9OgRHJlj/c7Ol5xpIP7bqLBNByjgt5paRE2eSu5AQHV92Ul+G6iw==} @@ -2605,8 +3177,8 @@ packages: react: '>=16.8' react-dom: '>=16.8' - '@tanstack/react-query@5.101.0': - resolution: {integrity: sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==} + '@tanstack/react-query@5.102.7': + resolution: {integrity: sha512-bbd8T4jDIj9aPbNn11SsjNH8apKY4zspkrw63JRsNDXjpW5SRF7X0ipi7yrGd8DCs8QhYfhQJKqEj1YLru1DbQ==} peerDependencies: react: ^18 || ^19 @@ -2634,12 +3206,12 @@ packages: '@tanstack/router-core': optional: true - '@tanstack/react-router-ssr-query@1.167.1': - resolution: {integrity: sha512-W9j5JPnBikyafvuUfykFfHIWod58OAbAAa5leNkXBcoDoocghMmu6w9uZOmUZvAWT7CSvgj5tBUtF7CM2OoHXQ==} + '@tanstack/react-router-ssr-query@1.167.2': + resolution: {integrity: sha512-yRvy0VJ00R8huPuquk+qyYQGMpYRc/G33mc6/yZ4f7LaBZz9h/VbACjmLzhm/+6u5WSmUt6ouJJZnI1/5Npfag==} engines: {node: '>=20.19'} peerDependencies: - '@tanstack/query-core': '>=5.90.0' - '@tanstack/react-query': '>=5.90.0' + '@tanstack/query-core': '>=5.102.0' + '@tanstack/react-query': '>=5.102.0' '@tanstack/react-router': '>=1.127.0' react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' @@ -2927,11 +3499,11 @@ packages: webpack: optional: true - '@tanstack/router-ssr-query-core@1.169.1': - resolution: {integrity: sha512-rngux8s/3mPQzcjLYDLkNU31coYVyCgrVTfpdwqUdY5jIEHqGTXrO73DTkPR1PppwYUeVhmNCgl8TctRcnupjg==} + '@tanstack/router-ssr-query-core@1.169.2': + resolution: {integrity: sha512-7pO65Aiq/1+aS3Mb6vSGtIjzQ/YGv9JTfBbn2EYlHcZnJ4s4ZGCeTPI847geB1RfJqVhfwxM8bZgGM51mYkolg==} engines: {node: '>=20.19'} peerDependencies: - '@tanstack/query-core': '>=5.90.0' + '@tanstack/query-core': '>=5.102.0' '@tanstack/router-core': '>=1.127.0' '@tanstack/router-utils@1.162.1': @@ -3087,33 +3659,33 @@ packages: peerDependencies: typescript: '>=5.7.2' - '@turbo/darwin-64@2.9.16': - resolution: {integrity: sha512-jLjApWTSNd7JZ5JaLYfelW1ytnGQOvB7ivl+2RD1xQvJTbi8I9gBjzcga7tDZVPyaxpl10YTfJt3BrYXR18KDw==} + '@turbo/darwin-64@2.10.12': + resolution: {integrity: sha512-9nKgKoF6ZOUsM+or0OtNf+TTJSfGvDNP7ZFv/ZGWVwOSCkumyctQiTeHwB4UNljHTnC41AqylgbunLDHoccNrA==} cpu: [x64] os: [darwin] - '@turbo/darwin-arm64@2.9.16': - resolution: {integrity: sha512-YPgrn+5HIGzrx0O2a631SV4MBQUe4W/DafMFUuBVgaU32PW9/OTT0ehviF0QSxTXuRJlHvW2eUTemddF5/spmw==} + '@turbo/darwin-arm64@2.10.12': + resolution: {integrity: sha512-H4Elb1jqTZVeIC9bbcNwjSzemZ6RegoTOVHeuV5Osirt2Z8UguTyisMEkvZjPVZgMeN9J4ERZBFad40tFnkb7w==} cpu: [arm64] os: [darwin] - '@turbo/linux-64@2.9.16': - resolution: {integrity: sha512-vAEf1H6l26lTpl9FJ/peQo1NUB8RC0sbEJJz5mPcUhHA2bPDup2x3CZPgo/bH8S4cUcBLm4FN3UHd5iUO2RAew==} + '@turbo/linux-64@2.10.12': + resolution: {integrity: sha512-lr7KIotukvjZwEXiFSYAeOH3BWzjFVBbSzTbv0fuGFsNukYyH0+g1hB5ecqnJkgkYU+KHEMG1edOhnjiKON1wQ==} cpu: [x64] - os: [linux] + os: [android, linux] - '@turbo/linux-arm64@2.9.16': - resolution: {integrity: sha512-xDBLR2PZg4BrQOchfG6svgpv5FCNJ2TOtT2psLdEJcdKo1BH+pnPs9Xj6pvUjgfkHbuvBOfeE4R6tvxMoQKDHQ==} + '@turbo/linux-arm64@2.10.12': + resolution: {integrity: sha512-f0pZDTtvzB5SuNwuXBaKbZHUCMCukgc8nMlHEuvLmj91Fzec+MEbr3cAvGNor5htEDqZnO6Lxt9N/GPI/77oGA==} cpu: [arm64] - os: [linux] + os: [android, linux] - '@turbo/windows-64@2.9.16': - resolution: {integrity: sha512-NBAJnaUiGdgkSzQwUIdOvkCkcpTSu58G/sBGa0mvBtzfvFOOgrQwepKOOQ8cp6sWM6OcKDNFj2p1dsZA1OWjPg==} + '@turbo/windows-64@2.10.12': + resolution: {integrity: sha512-SDOueJRjS/QcykWf2KCRtTLmIl5YMKsLbXkXQGhDwcTXvKXZiS5ih5lBl/gkwZIpYFjqA/rAlfMzlAFcVHNe0g==} cpu: [x64] os: [win32] - '@turbo/windows-arm64@2.9.16': - resolution: {integrity: sha512-Y7SJppD0Z8wjO3Ec0ZGd9KQ4Yv0BMnA8CIowj5Vp+OEVsosXDG2weK6/t1RRLfJmc2Ozrnd6y4DOgQys+mn3WQ==} + '@turbo/windows-arm64@2.10.12': + resolution: {integrity: sha512-0i0mVUa4kKk+/B3RwEwPMf9CB+T7ul56hn5FFHNA4VUNTOoLBEd6aNf3FaKfCatDNZ6cicCEf6if9QUTVyzzcA==} cpu: [arm64] os: [win32] @@ -3270,6 +3842,9 @@ packages: '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} @@ -3288,12 +3863,6 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node@22.19.20': - resolution: {integrity: sha512-6tELRwSDYWW9EdZhbeZmYGZ1/7Djkt+Ah3/ScEYT9cDord7UJzasR/4D3VONg9tQI5CDp+/CZC1AXj2pCFOvpw==} - - '@types/node@24.10.0': - resolution: {integrity: sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==} - '@types/node@25.9.1': resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==} @@ -3341,11 +3910,135 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@typescript/vfs@1.6.4': - resolution: {integrity: sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ==} - peerDependencies: - typescript: '*' - + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@typescript/typescript6@6.0.2': + resolution: {integrity: sha512-mbCddXd+jm7hfx7w2YU64/Av4/NqqeG3GoRZgxPcgoTxYjhrcfJRw9ULch71SS4G+Q3bOXFhRvPqjguN0Hyp5w==} + hasBin: true + + '@typescript/vfs@1.6.4': + resolution: {integrity: sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ==} + peerDependencies: + typescript: '*' + '@ungap/structured-clone@1.3.1': resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} @@ -3447,6 +4140,11 @@ packages: '@volar/typescript@2.4.28': resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true '@vue/compiler-core@3.5.35': resolution: {integrity: sha512-BUmHaR1J+O+CKZ9uJucdVTEr1LHsdyvv7vG3eNRhK3CczEHeMd/LtsHAuD7PbrxvI2envCY2v7HI1vC1aBRzKw==} @@ -3501,6 +4199,75 @@ packages: vue: optional: true + '@yuku-analyzer/binding-android-arm64@0.9.3': + resolution: {integrity: sha512-6dOwkawiJYtUUBchfjrFo7poz460Yg+aQNgr4lHUPZ2fNW+llpMEZkbznTq25BEmmiBGPJr+L15dzTvR8zP4vQ==} + cpu: [arm64] + os: [android] + + '@yuku-analyzer/binding-darwin-arm64@0.9.3': + resolution: {integrity: sha512-DTRoWK7AqNfshN+DcCS/s4n86br0ZusISeXLZWWNuvnZ3b9LCVXdWRVPlnMpEwsmSH3c3QSwL7MAOPyEHMe+FA==} + cpu: [arm64] + os: [darwin] + + '@yuku-analyzer/binding-darwin-x64@0.9.3': + resolution: {integrity: sha512-EWzaR0/AL3ikZfUiADG1SmbB+wF7GGhXFKhMWm3RbOTf+ATyEW7Fa9nsjo1K70pWEeVZHRm9MU4iq7LIUEEgfg==} + cpu: [x64] + os: [darwin] + + '@yuku-analyzer/binding-freebsd-x64@0.9.3': + resolution: {integrity: sha512-Ao4/v+ppzIFVs2SldvBM2hc9NsijXNsKc1xUY+d+Nv20HrjRdm96HtrkIViw7zI5i6Ca77jkd8VbhWE8E4kgyg==} + cpu: [x64] + os: [freebsd] + + '@yuku-analyzer/binding-linux-arm-gnu@0.9.3': + resolution: {integrity: sha512-EZc2H6bAyl4u3z48Jtqf4Un1657TpOkBWEmSdPCY1tHCO4YUFaNAz4dPA93yWgo5t38oRQT9UoXlwse/4aLtxw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@yuku-analyzer/binding-linux-arm-musl@0.9.3': + resolution: {integrity: sha512-pKny3wEa2Xl4kPoqNU8fO5NCzcp9VgMAkLpN3GZ5TOkdz23ppuzUqddlhad43/9puxfckX5aZqfxML/1vGoxBw==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@yuku-analyzer/binding-linux-arm64-gnu@0.9.3': + resolution: {integrity: sha512-bfpsIupfbX7N4ueSjo3Zm+Mob0q7MjrZX+INMGcPjNnXBcyKjkzrTotkqbEbwgfiNKhqyDVjynwsh64xsv5IPg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@yuku-analyzer/binding-linux-arm64-musl@0.9.3': + resolution: {integrity: sha512-Q+AAUrrsgYgHb0/Wrm+HnSKN7xOpkC+6Y2812dZw5/rrSX7+qjRjJ+3uNnCZlkHpH3Hy7WXbLIa/IncP4bcHQA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@yuku-analyzer/binding-linux-x64-gnu@0.9.3': + resolution: {integrity: sha512-ZQyjmSDRHTkDlddrzmIG1/nMUYDZC21XUguzQ4qvSWtfq6CsfJvIDFBPfJ03YDNQ+wR0px9Ly/xAa8VC0p9rHQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@yuku-analyzer/binding-linux-x64-musl@0.9.3': + resolution: {integrity: sha512-EIYzThB5pI3BiZHFNYyY8nMQ38z9l8/kT8uYvfYVpZ9TNEC0YgqX95MH61l9RILCYFDx+DbLoGajGY55JFvb6Q==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@yuku-analyzer/binding-win32-arm64@0.9.3': + resolution: {integrity: sha512-KF+Ho3jtjikfPYJ76NL1KiPYwF+0UjnsASX0k461BZ1Er4pRQG39o8WvsngLT+fDg6JxB3v5J+ScDrto29RVnA==} + cpu: [arm64] + os: [win32] + + '@yuku-analyzer/binding-win32-x64@0.9.3': + resolution: {integrity: sha512-4pdUfYVYPf05vyygvWiXsiI/tdPqn5bBiHs7c1TBIm3Kx7/w5pq++myNQ/8CUH/Proz+1kHtCr/lDJF9k67fqg==} + cpu: [x64] + os: [win32] + + '@yuku-toolchain/types@0.9.3': + resolution: {integrity: sha512-rFE+5P4g2wxko5C85MugJOlVjBHEQq87dIkhLkniLXLp63PEtgaFjD954i5HXlfnyzLxPcZHsSOVDVgmo1HToA==} + abbrev@2.0.0: resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -3568,13 +4335,12 @@ packages: resolution: {integrity: sha512-44mvgtPvohuU/70DdY5Oz2AIrLJ9k6/5x4KmoSvPwO+5Moijo0+N9D0fKbbYZQWP1hNm5CpOf+E01jhxG/r8xg==} engines: {node: '>=14'} + append-field@1.0.0: + resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - aria-hidden@1.2.6: - resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} - engines: {node: '>=10'} - aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} @@ -3586,6 +4352,15 @@ packages: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} + arkregex@0.0.8: + resolution: {integrity: sha512-PJcx6G1kQTgLKPUbeYlYecDRaKq15AMSGVajlKFYWlPeJRQL+j3dKE6tyMs40HZ99djS1l9Vhl3ezAHy9JBIqQ==} + + arktype@2.2.3: + resolution: {integrity: sha512-7W+0RLTUNJiBFIIZXwOQxSR8Z273IAd6IvqBeG9+gHnQKFsIx2C0iOtGTmMrPnlX4qLXyc5+ll7A0BIj9WrbTg==} + + array-ify@1.0.0: + resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==} + asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} @@ -3668,6 +4443,13 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -3688,6 +4470,10 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + caniuse-lite@1.0.30001793: resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} @@ -3747,6 +4533,14 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cnfast@0.0.8: + resolution: {integrity: sha512-EjXKMfGfdwtV4AcNSQ6AwQaVzpC1B7IxeiwA3FlhTXz+YFlMKVi4c1JX9tgD2QOlahQXjB8KUXrBaYG+3v871Q==} + hasBin: true + + cnfast@0.1.0: + resolution: {integrity: sha512-rH0jBKeLkVrK7NsZ5Ba2l7WdMBmm1k0FMpABeXUU1PgTUxbz3261gEuCSsrYuXo4BwAe3yCcIbQ93YyS52lOGQ==} + hasBin: true + collapse-white-space@2.1.0: resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} @@ -3780,12 +4574,19 @@ packages: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} engines: {node: '>= 12'} + compare-func@2.0.0: + resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} + component-emitter@1.3.1: resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} compute-scroll-into-view@3.1.1: resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==} + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + confbox@0.1.8: resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} @@ -3808,9 +4609,25 @@ packages: resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} engines: {node: '>=18'} + conventional-changelog-angular@8.3.1: + resolution: {integrity: sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg==} + engines: {node: '>=18'} + + conventional-changelog-conventionalcommits@9.3.1: + resolution: {integrity: sha512-dTYtpIacRpcZgrvBYvBfArMmK2xvIpv2TaxM0/ZI5CBtNUzvF2x0t15HsbRABWprS6UPmvj+PzHVjSx4qAVKyw==} + engines: {node: '>=18'} + + conventional-commits-parser@6.4.0: + resolution: {integrity: sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==} + engines: {node: '>=18'} + hasBin: true + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-es@1.2.3: + resolution: {integrity: sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==} + cookie-es@3.1.1: resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} @@ -3843,10 +4660,30 @@ packages: cose-base@2.2.0: resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + cosmiconfig-typescript-loader@6.3.0: + resolution: {integrity: sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA==} + engines: {node: '>=v18'} + peerDependencies: + '@types/node': '*' + cosmiconfig: '>=9' + typescript: '>=5' + + cosmiconfig@9.0.2: + resolution: {integrity: sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + crossws@0.3.5: + resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} + crossws@0.4.5: resolution: {integrity: sha512-wUR89x/Rw7/8t+vn0CmGDYM9TD6VtARGb0LD5jq2wjtMy1vCP4M+sm6N6TigWeTYvnA8MoW29NqqXD0ep0rfBA==} peerDependencies: @@ -4132,6 +4969,10 @@ packages: dompurify@3.4.7: resolution: {integrity: sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==} + dot-prop@5.3.0: + resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} + engines: {node: '>=8'} + drizzle-orm@1.0.0-rc.3: resolution: {integrity: sha512-akZOa5UxapFbdBG8IDkfBRpSZJpMHaOJtGgp7oi1oHaiU8S3KN92waHo2l5aRuv1D9tMGYpv3BQFOsiGcNjLTQ==} peerDependencies: @@ -4323,6 +5164,10 @@ packages: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + env-runner@0.1.9: resolution: {integrity: sha512-W9AiZlPx0uXtghAJiTBkeZOgyQdecVvoln3cHoOEZswPq0cVMi+WBhUQjdUn+JcZFAFgOt+i5fcO7C2zniZoCg==} hasBin: true @@ -4342,6 +5187,9 @@ packages: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -4370,8 +5218,8 @@ packages: esast-util-from-js@2.0.1: resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==} - esbuild@0.28.0: - resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} hasBin: true @@ -4514,6 +5362,10 @@ packages: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} + file-type@21.3.4: + resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==} + engines: {node: '>=20'} + file-type@22.0.1: resolution: {integrity: sha512-ww5Mhre0EE+jmBvOXTmXAbEMuZE7uX4a3+oRCQFNj8w++g3ev913N6tXQz0XTXbueQ5TWQfm6BdaViEHHn8bhA==} engines: {node: '>=22'} @@ -4533,6 +5385,9 @@ packages: find-workspaces@0.3.1: resolution: {integrity: sha512-UDkGILGJSA1LN5Aa7McxCid4sqW3/e+UYsVwyxki3dDT0F8+ym0rAfnCkEfkL0rO7M+8/mvkim4t/s3IPHmg+w==} + flexsearch@0.8.212: + resolution: {integrity: sha512-wSyJr1GUWoOOIISRu+X2IXiOcVfg9qqBRyCPRUdLMIGJqPzMo+jMRlvE83t14v1j0dRMEaBbER/adQjp6Du2pw==} + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -4549,15 +5404,12 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} - framer-motion@12.40.0: - resolution: {integrity: sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==} + framer-motion@13.1.1: + resolution: {integrity: sha512-B/xn2TPS4f61cEBLFjiYlQFnBZUW1YVj/LM+C+N4OP8Rs95VLEI2ot/RlfBg111la/EiyECFaJJi/A3FWA8MUA==} peerDependencies: - '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 peerDependenciesMeta: - '@emotion/is-prop-valid': - optional: true react: optional: true react-dom: @@ -4567,13 +5419,18 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - fumadocs-core@16.9.3: - resolution: {integrity: sha512-8RVzKnzBJR5o+tJCccY28ntekfMQYBoYiz7alnYb/d9YJc+XpnsINzTl63lQ1eBMZ9gdhm2MqRtgUjh/8rUrbw==} + fumadocs-core@16.15.4: + resolution: {integrity: sha512-kdOuM0tvHkLWajnDu73BmtryPuUq4xsu610ssl1YMAm9fYF7BRmvYxx2apHbyf2Lt+7w8OEiWDTmB8Ja3zqp4Q==} peerDependencies: '@mdx-js/mdx': '*' '@mixedbread/sdk': 0.x.x @@ -4590,7 +5447,7 @@ packages: next: 16.x.x react: ^19.2.0 react-dom: ^19.2.0 - react-router: 7.x.x + react-router: 7.x.x || 8.x.x waku: '*' zod: 4.x.x peerDependenciesMeta: @@ -4631,20 +5488,24 @@ packages: zod: optional: true - fumadocs-mdx@15.0.10: - resolution: {integrity: sha512-kH3S7ESS9yXTAaCkA8dDugsCK/MbnpgyZ5qBEL7cWoavV0O/T4+4YTYFkvNknz7cw+T/r+OG0p2BvlVhkk4fww==} + fumadocs-mdx@15.4.0: + resolution: {integrity: sha512-bJCQfsckKUQ4x2pyz8OdASEAARVMB49kOej7njaJ3t+tYsP1HnwkmqiO8e/jdQHcv6JH3XkXWidTho3ZC/WBcA==} hasBin: true peerDependencies: + '@fumadocs/satteri': 0.x.x '@types/mdast': '*' '@types/mdx': '*' '@types/react': '*' - fumadocs-core: ^16.7.0 + fumadocs-core: ^16.15.3 mdast-util-directive: '*' next: ^15.3.0 || ^16.0.0 react: ^19.2.0 rolldown: '*' + satteri: ^0.10.5 vite: 7.x.x || 8.x.x peerDependenciesMeta: + '@fumadocs/satteri': + optional: true '@types/mdast': optional: true '@types/mdx': @@ -4659,11 +5520,13 @@ packages: optional: true rolldown: optional: true + satteri: + optional: true vite: optional: true - fumadocs-twoslash@3.2.0: - resolution: {integrity: sha512-hzoxc2HR9dw2z5T1NNLYCbMY1DFiSF71+X2KJS4t/BalRyiBxpCcP9zAyzofba07YkrGhZ6xW3B105EcpNI2Vw==} + fumadocs-twoslash@3.3.0: + resolution: {integrity: sha512-IR+oYbjsR59h/R7AvdeEdHEY48ygCHII3tUZGC3QnUVug5+qN1wlRNr30gAfOvrau/mdiRKwONuJCOKOWqEFbg==} peerDependencies: '@types/react': '*' fumadocs-core: ^16.7.16 @@ -4675,26 +5538,6 @@ packages: '@types/react': optional: true - fumadocs-ui@16.9.3: - resolution: {integrity: sha512-eoVKj1H+ATut0su+WIoPWBLRqzPMGD0hekIBr4GopWvUg1lS997HL4kP+Leyf+3CYlZtFgyXb6ylbvRLFtEj6Q==} - peerDependencies: - '@takumi-rs/image-response': '*' - '@types/mdx': '*' - '@types/react': '*' - fumadocs-core: 16.9.3 - next: 16.x.x - react: ^19.2.0 - react-dom: ^19.2.0 - peerDependenciesMeta: - '@takumi-rs/image-response': - optional: true - '@types/mdx': - optional: true - '@types/react': - optional: true - next: - optional: true - function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -4733,6 +5576,12 @@ packages: resolution: {integrity: sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ==} engines: {node: '>=20.20.0'} + git-raw-commits@5.0.1: + resolution: {integrity: sha512-Y+csSm2GD/PCSh6Isd/WiMjNAydu0VBiG9J7EdQsNA5P9uXvLayqjmTsNlK5Gs9IhblFZqOU0yid5Il5JPoLiQ==} + engines: {node: '>=18'} + deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead. + hasBin: true + github-slugger@2.0.0: resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} @@ -4745,6 +5594,10 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true + global-directory@5.0.0: + resolution: {integrity: sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w==} + engines: {node: '>=20'} + globrex@0.1.2: resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} @@ -4760,6 +5613,9 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + h3@1.15.11: + resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==} + h3@2.0.1-rc.20: resolution: {integrity: sha512-28ljodXuUp0fZovdiSRq4G9OgrxCztrJe5VdYzXAB7ueRvI7pIUqLU14Xi3XqdYJ/khXjfpUOOD2EQa6CmBgsg==} engines: {node: '>=20.11.1'} @@ -4834,6 +5690,10 @@ packages: resolution: {integrity: sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==} engines: {node: '>=16.9.0'} + hono@4.13.5: + resolution: {integrity: sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==} + engines: {node: '>=16.9.0'} + hookable@6.1.1: resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==} @@ -4885,6 +5745,10 @@ packages: ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + import-meta-resolve@4.2.0: resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} @@ -4902,6 +5766,10 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + ini@6.0.0: + resolution: {integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==} + engines: {node: ^20.17.0 || >=22.9.0} + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -4920,12 +5788,18 @@ packages: resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==} engines: {node: '>= 10'} + iron-webcrypto@1.2.1: + resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} + is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} is-alphanumerical@2.0.1: resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + is-decimal@2.0.1: resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} @@ -4952,6 +5826,10 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-obj@2.0.0: + resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} + engines: {node: '>=8'} + is-plain-obj@4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} @@ -5000,9 +5878,17 @@ packages: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} + iterare@1.2.1: + resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==} + engines: {node: '>=6'} + jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true @@ -5039,6 +5925,9 @@ packages: engines: {node: '>=6'} hasBin: true + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-parse-even-better-errors@6.0.0: resolution: {integrity: sha512-2/8adwnK1/+Fdjyts4r6wSpfANWw8zdNhU9U/Llk59c6O+DjSisPWPykwoL8gZmocP9Dy64S7oie2g+Mia123A==} engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} @@ -5154,6 +6043,13 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + load-esm@1.0.3: + resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==} + engines: {node: '>=13.2.0'} + locate-character@3.0.0: resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} @@ -5182,8 +6078,8 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 - lucide-react@1.17.0: - resolution: {integrity: sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==} + lucide-react@1.34.0: + resolution: {integrity: sha512-vnjGJNI7Htk5+oWW8gXGuaLgwgAb0T6/iZbBrp9JCfRFwdNWZ0YTm3eyxjOLgwN6r8iyAf3UA70zNmBRBNv7yg==} peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -5194,8 +6090,8 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - magicast@0.5.3: - resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + magic-string@1.2.3: + resolution: {integrity: sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==} magicast@0.5.4: resolution: {integrity: sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==} @@ -5271,6 +6167,10 @@ packages: mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + media-typer@1.1.0: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} @@ -5282,6 +6182,10 @@ packages: resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==} engines: {node: '>= 0.10.0'} + meow@13.2.0: + resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} + engines: {node: '>=18'} + merge-anything@5.1.7: resolution: {integrity: sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ==} engines: {node: '>=12.13'} @@ -5443,6 +6347,9 @@ packages: resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} @@ -5450,21 +6357,18 @@ packages: mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} - motion-dom@12.40.0: - resolution: {integrity: sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==} + motion-dom@13.1.1: + resolution: {integrity: sha512-XSf8VYWSB6G/0IY3rWVbyLcxWXtAVHkN1PQE2agTaCv3u8RGvbwu56TyyR/MNzBqqNavEBTZzErcxI1TxBrjcA==} - motion-utils@12.39.0: - resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==} + motion-utils@13.0.0: + resolution: {integrity: sha512-7DnN7TmbLcYXcG4RVadXIihWlyuM9afoUww8Y5Agg431kGKiuL2/OMyP4mJ5wLz+pvN3t5ySClLOaVXJ+wekRQ==} - motion@12.40.0: - resolution: {integrity: sha512-yjrHUrBFW6kQvjJwRsoiPSAhC5tRwRqNGJWmiJ4CrGnbKp0V88AdzkhBmDoqIsIPfarOe0Uddd37Xq43/gIocA==} + motion@13.1.1: + resolution: {integrity: sha512-WNZoK6xiF+kkTqkZ5K7FDDh6A8BG4i5Hc7KXtW8gtTxkpJFds+hIOrDaQGKjQj/AE/i4hJqAaUHEqp/Qo02y6Q==} peerDependencies: - '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 peerDependenciesMeta: - '@emotion/is-prop-valid': - optional: true react: optional: true react-dom: @@ -5484,11 +6388,20 @@ packages: muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + multer@2.2.0: + resolution: {integrity: sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==} + engines: {node: '>= 10.16.0'} + nanoid@3.3.12: resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} @@ -5499,9 +6412,9 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - next@16.2.6: - resolution: {integrity: sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==} - engines: {node: '>=20.9.0'} + next@15.5.24: + resolution: {integrity: sha512-Y+xn8EQCoC3ZbsFPyzE+tE8XOdrWeUdUF7NeXbmg9DsgAxl5UYxlsrvgVESHTyTGigoTa1bCUrxn70F5bqt0Gw==} + engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} hasBin: true peerDependencies: '@opentelemetry/api': ^1.1.0 @@ -5520,47 +6433,113 @@ packages: sass: optional: true - nf3@0.3.17: - resolution: {integrity: sha512-N9zEWySuJFw+gR0lhS5863YsvNeudOdqRyFvNb+jMXbeTJOdrjDqkCpDginIZfUm0LzT1t1nCRiDeqQm/8kirQ==} - - nitro@3.0.260522-beta: - resolution: {integrity: sha512-L/z2eOWgkiQHc65kv+SEMgau505afSRF7NJlbooaaZEZscFrNSD7rXZzeVubQlgIzPbhOG8o73bk9soIiGTHRA==} - engines: {node: ^20.19.0 || >=22.12.0} + next@16.0.11: + resolution: {integrity: sha512-Xlo2aFWaoypPzXr4PFLSNmxrzNptlp+hgxnG9Y2THYvHrvmXIuHUyNAWO6Q+F4rm4/bmTOukprXEyF/j4qsC2A==} + engines: {node: '>=20.9.0'} hasBin: true peerDependencies: - '@vercel/queue': ^0.2.0 - dotenv: '*' - giget: '*' - jiti: ^2.6.1 - rollup: ^4.60.3 - vite: ^7 || ^8 - xml2js: ^0.6.2 - zephyr-agent: ^0.2.0 + '@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: - '@vercel/queue': - optional: true - dotenv: - optional: true - giget: - optional: true - jiti: - optional: true - rollup: + '@opentelemetry/api': optional: true - vite: + '@playwright/test': optional: true - xml2js: + babel-plugin-react-compiler: optional: true - zephyr-agent: + sass: optional: true - node-fetch-native@1.6.7: - resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} - - node-releases@2.0.46: - resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==} - engines: {node: '>=18'} - + next@16.2.6: + resolution: {integrity: sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==} + engines: {node: '>=20.9.0'} + hasBin: true + 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 + + next@16.3.3: + resolution: {integrity: sha512-tuRTx1nQ/yVw83cwJBo9F+njGUgMn3UHQycreWHB8XsStvvAh1AthbI8/4IpKnFaF58F+iSiHejYOlMQ/eq83g==} + engines: {node: '>=20.9.0'} + hasBin: true + 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 + + nf3@0.3.17: + resolution: {integrity: sha512-N9zEWySuJFw+gR0lhS5863YsvNeudOdqRyFvNb+jMXbeTJOdrjDqkCpDginIZfUm0LzT1t1nCRiDeqQm/8kirQ==} + + nitro@3.0.260522-beta: + resolution: {integrity: sha512-L/z2eOWgkiQHc65kv+SEMgau505afSRF7NJlbooaaZEZscFrNSD7rXZzeVubQlgIzPbhOG8o73bk9soIiGTHRA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@vercel/queue': ^0.2.0 + dotenv: '*' + giget: '*' + jiti: ^2.6.1 + rollup: ^4.60.3 + vite: ^7 || ^8 + xml2js: ^0.6.2 + zephyr-agent: ^0.2.0 + peerDependenciesMeta: + '@vercel/queue': + optional: true + dotenv: + optional: true + giget: + optional: true + jiti: + optional: true + rollup: + optional: true + vite: + optional: true + xml2js: + optional: true + zephyr-agent: + optional: true + + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + + node-mock-http@1.0.5: + resolution: {integrity: sha512-KQyt/wLjG3TAc7DOUhpqWzgd4ERxR80JOlTK5VE5R1S12IaPVN5qkj4klBce9HPG1Njuup4Sb5bljaT34lIyjw==} + + node-releases@2.0.46: + resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==} + engines: {node: '>=18'} + nopt@7.2.1: resolution: {integrity: sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -5579,6 +6558,10 @@ packages: resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} engines: {node: '>=18'} + npm-to-yarn@3.2.0: + resolution: {integrity: sha512-K1HmQeZT2HrjpsR6KgqbN2FAXL2NrJJmNUSD9ck7HGTVu1JKXox8n9SB+tjbU8m8JGLF4OscrroPepew/L7/Xw==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + nypm@0.6.9: resolution: {integrity: sha512-zxlE2yvSWZWmHcNdT3+5zV2lrCogeE9YOklHrR3dFjqutq5wO7GFDYLFDRXLsYnJzwvy/im9fYoxePvS0VTW0w==} engines: {node: '>=18'} @@ -5635,6 +6618,10 @@ packages: resolution: {integrity: sha512-WyPWZlcIm+Fkte63FGfgFB8mAAk33aH9h5N9lphXVOHSXEBFFsmYdOBedVKly363aWABjZdaj/m9lBfEY4wt+w==} engines: {node: ^20.19.0 || >=22.12.0} + oxc-parser@0.147.0: + resolution: {integrity: sha512-5xaug6t7GfV3BO5Iv+xHW1rmQkDEQ3BEu3L8g3InsvWO5i8CYGc4tCZ2X985QcwWNycFJam+aOns6Nr2XAThTA==} + engines: {node: ^20.19.0 || >=22.12.0} + oxfmt@0.64.0: resolution: {integrity: sha512-XZ4GFBN/PLbXKq+0zrgpQfPKYuJlUuj+nzZJY7UpIbFMNyefNLCdN9EwViycNqnYcv0wrn0jXcQLlqJp8RCKBg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5671,9 +6658,17 @@ packages: package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + parse-ms@4.0.0: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} @@ -5756,6 +6751,16 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + pnpm-workspace-yaml@1.6.1: resolution: {integrity: sha512-yTeZntGWi8m9WNuhoVsP0DpFc4sC1U0+rr/qR6Zi9n2g3sxXY+JfccjXjjruNz96tM8I09yaJUA86doRnNLkbg==} @@ -5777,6 +6782,10 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.23: + resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} + engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} engines: {node: '>=4'} @@ -5846,6 +6855,9 @@ packages: resolution: {integrity: sha512-h36JMxKRqrAxVD8201FrCpyeNuUY9Y5zZwujr20fFO77tpUtGa6EZzfKw/3WaiBX95fq7+MpsuMLNdSnORAwSA==} engines: {node: '>=14.18.0'} + radix3@1.1.2: + resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} + range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -5854,11 +6866,6 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} - react-dom@19.2.4: - resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} - peerDependencies: - react: ^19.2.4 - react-dom@19.2.6: resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==} peerDependencies: @@ -5897,10 +6904,6 @@ packages: '@types/react': optional: true - react@19.2.4: - resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} - engines: {node: '>=0.10.0'} - react@19.2.6: resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} engines: {node: '>=0.10.0'} @@ -5909,6 +6912,10 @@ packages: resolution: {integrity: sha512-PNaGjoCnw9DBA2Kl8D+8po957z778q/HOPuY2u3Bkw/JO3eC8MDx7jn/PgMtSgpcBbs+6UOjDbwReGpXmRvs0g==} engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -5942,6 +6949,9 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + regex-recursion@6.0.2: resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} @@ -5983,6 +6993,17 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + reselect@5.3.0: + resolution: {integrity: sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -6048,10 +7069,16 @@ packages: rw@1.3.3: resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + sade@1.8.1: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} engines: {node: '>=6'} + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-regex2@5.1.1: resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} hasBin: true @@ -6088,6 +7115,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + send@1.2.1: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} @@ -6129,6 +7161,15 @@ packages: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + sharp@0.35.4: + resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -6141,8 +7182,8 @@ packages: resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==} engines: {node: '>= 0.4'} - shiki@4.1.0: - resolution: {integrity: sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q==} + shiki@4.4.3: + resolution: {integrity: sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==} engines: {node: '>=20'} side-channel-list@1.0.1: @@ -6220,6 +7261,10 @@ packages: std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -6232,6 +7277,9 @@ packages: resolution: {integrity: sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==} engines: {node: '>=20'} + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -6457,15 +7505,15 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - turbo@2.9.16: - resolution: {integrity: sha512-NqgRQy6j6dPYcdSdv0q1g9QsZg7SWg87RERM8otw/1AtKU2yTFVClOM7cbwKzOonZr/Ek1blTBucw64L9H0Bwg==} + turbo@2.10.12: + resolution: {integrity: sha512-AswgMPnpOoaVZHrrSBejETzEbuIA69OVGwfkHwfrY0A23VjWXBANzgq9+OymWOHAIArB7D1+1z498WY8fGg1Jw==} hasBin: true - twoslash-protocol@0.3.8: - resolution: {integrity: sha512-HmvAHoiEviK8LqvAQyc9/irkdvwTUiR1fHmNwH/0gq8EHxyBt4PWVPixjEXg6wJu1u6yBrILEWXGK9Kw58/8yQ==} + twoslash-protocol@0.3.9: + resolution: {integrity: sha512-9/iwp+CXOnjFMPQuPL5PkuRbZnDoNpBvtJCLs9t8kDYkL3YHujbvnHfZA1i5fApDftVEdBw+T/4F+dH5kIzpYQ==} - twoslash@0.3.8: - resolution: {integrity: sha512-OeDz0kDl8sqPUN3nr7gqcvOs70f5lZsdhKYTX3/SgB9OvdadzzoYJI/4SBXhXV1HG8E9fLc+e17itoRYTxmoig==} + twoslash@0.3.9: + resolution: {integrity: sha512-rDclk+OtzuTX+tnea7DYLCkqGQ3eP0IyfD+kzUJ7t46X/NzlaxwrhecmEBNuSCuEn3V+n1PhcjUUQQ7gUJzX5Q==} peerDependencies: typescript: ^5.5.0 || ^6.0.0 @@ -6473,18 +7521,39 @@ packages: resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==} engines: {node: '>=20'} + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + type-is@2.1.0: resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} engines: {node: '>= 18'} + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} hasBin: true + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + uid@2.0.2: + resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} + engines: {node: '>=8'} + uint8array-extras@1.5.0: resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} engines: {node: '>=18'} @@ -6507,11 +7576,8 @@ packages: unconfig@7.5.0: resolution: {integrity: sha512-oi8Qy2JV4D3UQ0PsopR28CzdQ3S/5A1zwsUwp/rosSbfhJ5z7b90bIyTwi/F7hCLD4SGcZVjDzd4XoUQcEanvA==} - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - - undici-types@7.16.0: - resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} @@ -6671,6 +7737,14 @@ packages: resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==} hasBin: true + valibot@1.4.2: + resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -6928,6 +8002,16 @@ packages: resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==} engines: {node: '>=18'} + yuku-analyzer@0.9.3: + resolution: {integrity: sha512-2xSREErroEF8boH7XyKfVO5hbjP6PCIYYnLR2Abg6PGJaFVbETLucmbzIebBOHeyY4GO6VXzloTuhUldR/zyew==} + + yuku-ast@0.9.3: + resolution: {integrity: sha512-Kt0PXlPCXdKF1fWFTl7N3DaxsVk6DHF8VAXHJMkwPtJnWYgbx20pYgGSweuC/86mIM7k1NUITQ4JffgOLUWTCw==} + + zbsearch@4.0.0: + resolution: {integrity: sha512-gm4zfO31n2ZdruTTRQoWVWO4Q2+zrDt2GlrvIc+5JulRQNAm4IanCxER82vQ7Ug96FYkGqWUJBXFe1kGAcDWaQ==} + engines: {node: '>= 20.0.0'} + zimmerframe@1.1.4: resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} @@ -6948,7 +8032,7 @@ snapshots: '@antfu/install-pkg@1.1.0': dependencies: package-manager-detector: 1.6.0 - tinyexec: 1.2.3 + tinyexec: 1.3.0 '@antfu/ni@30.1.0': dependencies: @@ -6957,6 +8041,12 @@ snapshots: tinyexec: 1.2.3 tinyglobby: 0.2.17 + '@ark/schema@0.56.2': + dependencies: + '@ark/util': 0.56.2 + + '@ark/util@0.56.2': {} + '@asamuzakjp/css-color@5.1.11': dependencies: '@asamuzakjp/generational-cache': 1.0.1 @@ -6991,20 +8081,20 @@ snapshots: '@babel/compat-data@7.29.7': {} - '@babel/core@7.29.7': + '@babel/core@7.29.7(supports-color@7.2.0)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.7 '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) '@babel/helpers': 7.29.7 '@babel/parser': 7.29.7 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@7.2.0) '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -7042,19 +8132,19 @@ snapshots: dependencies: '@babel/types': 7.29.7 - '@babel/helper-module-imports@7.29.7': + '@babel/helper-module-imports@7.29.7(supports-color@7.2.0)': dependencies: - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@7.2.0) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.29.7 + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-module-imports': 7.29.7(supports-color@7.2.0) '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -7083,14 +8173,14 @@ snapshots: dependencies: '@babel/types': 8.0.0-rc.6 - '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.29.7 '@babel/runtime@7.29.7': {} @@ -7101,7 +8191,7 @@ snapshots: '@babel/parser': 7.29.7 '@babel/types': 7.29.7 - '@babel/traverse@7.29.7': + '@babel/traverse@7.29.7(supports-color@7.2.0)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.7 @@ -7109,7 +8199,7 @@ snapshots: '@babel/parser': 7.29.7 '@babel/template': 7.29.7 '@babel/types': 7.29.7 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -7123,6 +8213,29 @@ snapshots: '@babel/helper-string-parser': 8.0.0-rc.6 '@babel/helper-validator-identifier': 8.0.0-rc.6 + '@base-ui/react@1.7.0(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@babel/runtime': 7.29.7 + '@base-ui/utils': 0.3.2(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@floating-ui/react-dom': 2.1.9(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@floating-ui/utils': 0.2.12 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + use-sync-external-store: 1.6.0(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.15 + + '@base-ui/utils@0.3.2(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@babel/runtime': 7.29.7 + '@floating-ui/utils': 0.2.12 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + reselect: 5.3.0 + use-sync-external-store: 1.6.0(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.15 + '@bcoe/v8-coverage@1.0.2': {} '@borewit/text-codec@0.2.2': {} @@ -7147,6 +8260,124 @@ snapshots: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 + '@commitlint/cli@20.5.3(@types/node@25.9.1)(conventional-commits-parser@6.4.0)(typescript@7.0.2)': + dependencies: + '@commitlint/format': 20.5.0 + '@commitlint/lint': 20.5.3 + '@commitlint/load': 20.5.3(@types/node@25.9.1)(typescript@7.0.2) + '@commitlint/read': 20.5.0(conventional-commits-parser@6.4.0) + '@commitlint/types': 20.5.0 + tinyexec: 1.3.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - conventional-commits-filter + - conventional-commits-parser + - typescript + + '@commitlint/config-conventional@20.5.3': + dependencies: + '@commitlint/types': 20.5.0 + conventional-changelog-conventionalcommits: 9.3.1 + + '@commitlint/config-validator@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + ajv: 8.20.0 + + '@commitlint/ensure@20.5.3': + dependencies: + '@commitlint/types': 20.5.0 + es-toolkit: 1.47.0 + + '@commitlint/execute-rule@20.0.0': {} + + '@commitlint/format@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + picocolors: 1.1.1 + + '@commitlint/is-ignored@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + semver: 7.8.5 + + '@commitlint/lint@20.5.3': + dependencies: + '@commitlint/is-ignored': 20.5.0 + '@commitlint/parse': 20.5.0 + '@commitlint/rules': 20.5.3 + '@commitlint/types': 20.5.0 + + '@commitlint/load@20.5.3(@types/node@25.9.1)(typescript@7.0.2)': + dependencies: + '@commitlint/config-validator': 20.5.0 + '@commitlint/execute-rule': 20.0.0 + '@commitlint/resolve-extends': 20.5.3 + '@commitlint/types': 20.5.0 + cosmiconfig: 9.0.2(typescript@7.0.2) + cosmiconfig-typescript-loader: 6.3.0(@types/node@25.9.1)(cosmiconfig@9.0.2(typescript@7.0.2))(typescript@7.0.2) + es-toolkit: 1.47.0 + is-plain-obj: 4.1.0 + picocolors: 1.1.1 + transitivePeerDependencies: + - '@types/node' + - typescript + + '@commitlint/message@20.4.3': {} + + '@commitlint/parse@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + conventional-changelog-angular: 8.3.1 + conventional-commits-parser: 6.4.0 + + '@commitlint/read@20.5.0(conventional-commits-parser@6.4.0)': + dependencies: + '@commitlint/top-level': 20.4.3 + '@commitlint/types': 20.5.0 + git-raw-commits: 5.0.1(conventional-commits-parser@6.4.0) + minimist: 1.2.8 + tinyexec: 1.3.0 + transitivePeerDependencies: + - conventional-commits-filter + - conventional-commits-parser + + '@commitlint/resolve-extends@20.5.3': + dependencies: + '@commitlint/config-validator': 20.5.0 + '@commitlint/types': 20.5.0 + es-toolkit: 1.47.0 + global-directory: 5.0.0 + import-meta-resolve: 4.2.0 + resolve-from: 5.0.0 + + '@commitlint/rules@20.5.3': + dependencies: + '@commitlint/ensure': 20.5.3 + '@commitlint/message': 20.4.3 + '@commitlint/to-lines': 20.0.0 + '@commitlint/types': 20.5.0 + + '@commitlint/to-lines@20.0.0': {} + + '@commitlint/top-level@20.4.3': + dependencies: + escalade: 3.2.0 + + '@commitlint/types@20.5.0': + dependencies: + conventional-commits-parser: 6.4.0 + picocolors: 1.1.1 + + '@conventional-changelog/git-client@2.7.0(conventional-commits-parser@6.4.0)': + dependencies: + '@simple-libs/child-process-utils': 1.0.2 + '@simple-libs/stream-utils': 1.2.0 + semver: 7.8.5 + optionalDependencies: + conventional-commits-parser: 6.4.0 + '@csstools/color-helpers@6.0.2': {} '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': @@ -7182,87 +8413,92 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/wasi-threads@1.2.1': dependencies: tslib: 2.8.1 optional: true - '@esbuild/aix-ppc64@0.28.0': + '@esbuild/aix-ppc64@0.28.2': optional: true - '@esbuild/android-arm64@0.28.0': + '@esbuild/android-arm64@0.28.2': optional: true - '@esbuild/android-arm@0.28.0': + '@esbuild/android-arm@0.28.2': optional: true - '@esbuild/android-x64@0.28.0': + '@esbuild/android-x64@0.28.2': optional: true - '@esbuild/darwin-arm64@0.28.0': + '@esbuild/darwin-arm64@0.28.2': optional: true - '@esbuild/darwin-x64@0.28.0': + '@esbuild/darwin-x64@0.28.2': optional: true - '@esbuild/freebsd-arm64@0.28.0': + '@esbuild/freebsd-arm64@0.28.2': optional: true - '@esbuild/freebsd-x64@0.28.0': + '@esbuild/freebsd-x64@0.28.2': optional: true - '@esbuild/linux-arm64@0.28.0': + '@esbuild/linux-arm64@0.28.2': optional: true - '@esbuild/linux-arm@0.28.0': + '@esbuild/linux-arm@0.28.2': optional: true - '@esbuild/linux-ia32@0.28.0': + '@esbuild/linux-ia32@0.28.2': optional: true - '@esbuild/linux-loong64@0.28.0': + '@esbuild/linux-loong64@0.28.2': optional: true - '@esbuild/linux-mips64el@0.28.0': + '@esbuild/linux-mips64el@0.28.2': optional: true - '@esbuild/linux-ppc64@0.28.0': + '@esbuild/linux-ppc64@0.28.2': optional: true - '@esbuild/linux-riscv64@0.28.0': + '@esbuild/linux-riscv64@0.28.2': optional: true - '@esbuild/linux-s390x@0.28.0': + '@esbuild/linux-s390x@0.28.2': optional: true - '@esbuild/linux-x64@0.28.0': + '@esbuild/linux-x64@0.28.2': optional: true - '@esbuild/netbsd-arm64@0.28.0': + '@esbuild/netbsd-arm64@0.28.2': optional: true - '@esbuild/netbsd-x64@0.28.0': + '@esbuild/netbsd-x64@0.28.2': optional: true - '@esbuild/openbsd-arm64@0.28.0': + '@esbuild/openbsd-arm64@0.28.2': optional: true - '@esbuild/openbsd-x64@0.28.0': + '@esbuild/openbsd-x64@0.28.2': optional: true - '@esbuild/openharmony-arm64@0.28.0': + '@esbuild/openharmony-arm64@0.28.2': optional: true - '@esbuild/sunos-x64@0.28.0': + '@esbuild/sunos-x64@0.28.2': optional: true - '@esbuild/win32-arm64@0.28.0': + '@esbuild/win32-arm64@0.28.2': optional: true - '@esbuild/win32-ia32@0.28.0': + '@esbuild/win32-ia32@0.28.2': optional: true - '@esbuild/win32-x64@0.28.0': + '@esbuild/win32-x64@0.28.2': optional: true '@exodus/bytes@1.15.1(@noble/hashes@1.8.0)': @@ -7292,28 +8528,63 @@ snapshots: '@fastify/forwarded': 3.0.1 ipaddr.js: 2.4.0 - '@floating-ui/core@1.7.5': + '@floating-ui/core@1.8.0': dependencies: - '@floating-ui/utils': 0.2.11 + '@floating-ui/utils': 0.2.12 - '@floating-ui/dom@1.7.6': + '@floating-ui/dom@1.8.0': dependencies: - '@floating-ui/core': 1.7.5 - '@floating-ui/utils': 0.2.11 + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 - '@floating-ui/react-dom@2.1.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@floating-ui/react-dom@2.1.9(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@floating-ui/dom': 1.7.6 + '@floating-ui/dom': 1.8.0 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) - '@floating-ui/utils@0.2.11': {} + '@floating-ui/utils@0.2.12': {} - '@fumadocs/tailwind@0.0.5(@tailwindcss/oxide@4.3.0)(tailwindcss@4.3.0)': + '@fuma-translate/react@1.0.2(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.15 + + '@fumadocs/base-ui@16.15.4(@types/mdx@2.0.13)(@types/react@19.2.15)(fumadocs-core@16.15.4(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@tanstack/react-router@1.170.15(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.15)(flexsearch@0.8.212)(lucide-react@1.34.0(react@19.2.6))(next@16.3.3(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.62.1)(@types/node@25.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(supports-color@7.2.0)(zod@4.4.3))(next@16.3.3(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.62.1)(@types/node@25.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tailwindcss@4.3.0)': + dependencies: + '@base-ui/react': 1.7.0(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@fuma-translate/react': 1.0.2(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@fumadocs/tailwind': 0.1.1(tailwindcss@4.3.0) + class-variance-authority: 0.7.1 + cnfast: 0.1.0 + fumadocs-core: 16.15.4(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@tanstack/react-router@1.170.15(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.15)(flexsearch@0.8.212)(lucide-react@1.34.0(react@19.2.6))(next@16.3.3(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.62.1)(@types/node@25.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(supports-color@7.2.0)(zod@4.4.3) + lucide-react: 1.34.0(react@19.2.6) + motion: 13.1.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + next-themes: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-remove-scroll: 2.7.2(@types/react@19.2.15)(react@19.2.6) + rehype-raw: 7.0.0 + scroll-into-view-if-needed: 3.1.0 + shiki: 4.4.3 + unist-util-visit: 5.1.0 + optionalDependencies: + '@types/mdx': 2.0.13 + '@types/react': 19.2.15 + next: 16.3.3(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.62.1)(@types/node@25.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + transitivePeerDependencies: + - '@date-fns/tz' + - date-fns + - tailwindcss + + '@fumadocs/tailwind@0.1.1(tailwindcss@4.3.0)': optionalDependencies: - '@tailwindcss/oxide': 4.3.0 tailwindcss: 4.3.0 + '@fumari/image-size@0.1.0': {} + '@henrygd/queue@1.2.0': {} '@iconify/types@2.0.0': {} @@ -7332,95 +8603,199 @@ snapshots: '@img/sharp-libvips-darwin-arm64': 1.2.4 optional: true + '@img/sharp-darwin-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.3 + optional: true + '@img/sharp-darwin-x64@0.34.5': optionalDependencies: '@img/sharp-libvips-darwin-x64': 1.2.4 optional: true - '@img/sharp-libvips-darwin-arm64@1.2.4': + '@img/sharp-darwin-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.3 optional: true - '@img/sharp-libvips-darwin-x64@1.2.4': + '@img/sharp-freebsd-wasm32@0.35.4': + dependencies: + '@img/sharp-wasm32': 0.35.4 optional: true - '@img/sharp-libvips-linux-arm64@1.2.4': + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.3': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.3': optional: true '@img/sharp-libvips-linux-arm@1.2.4': optional: true + '@img/sharp-libvips-linux-arm@1.3.3': + optional: true + '@img/sharp-libvips-linux-ppc64@1.2.4': optional: true + '@img/sharp-libvips-linux-ppc64@1.3.3': + optional: true + '@img/sharp-libvips-linux-riscv64@1.2.4': optional: true + '@img/sharp-libvips-linux-riscv64@1.3.3': + optional: true + '@img/sharp-libvips-linux-s390x@1.2.4': optional: true + '@img/sharp-libvips-linux-s390x@1.3.3': + optional: true + '@img/sharp-libvips-linux-x64@1.2.4': optional: true + '@img/sharp-libvips-linux-x64@1.3.3': + optional: true + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': optional: true + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + optional: true + '@img/sharp-libvips-linuxmusl-x64@1.2.4': optional: true + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + optional: true + '@img/sharp-linux-arm64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-arm64': 1.2.4 optional: true + '@img/sharp-linux-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.3 + optional: true + '@img/sharp-linux-arm@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-arm': 1.2.4 optional: true + '@img/sharp-linux-arm@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.3 + optional: true + '@img/sharp-linux-ppc64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-ppc64': 1.2.4 optional: true + '@img/sharp-linux-ppc64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.3 + optional: true + '@img/sharp-linux-riscv64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-riscv64': 1.2.4 optional: true + '@img/sharp-linux-riscv64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.3 + optional: true + '@img/sharp-linux-s390x@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-s390x': 1.2.4 optional: true + '@img/sharp-linux-s390x@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.3 + optional: true + '@img/sharp-linux-x64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-x64': 1.2.4 optional: true + '@img/sharp-linux-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.3 + optional: true + '@img/sharp-linuxmusl-arm64@0.34.5': optionalDependencies: '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 optional: true + '@img/sharp-linuxmusl-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + optional: true + '@img/sharp-linuxmusl-x64@0.34.5': optionalDependencies: '@img/sharp-libvips-linuxmusl-x64': 1.2.4 optional: true + '@img/sharp-linuxmusl-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + optional: true + '@img/sharp-wasm32@0.34.5': dependencies: '@emnapi/runtime': 1.10.0 optional: true + '@img/sharp-wasm32@0.35.4': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.4': + dependencies: + '@img/sharp-wasm32': 0.35.4 + optional: true + '@img/sharp-win32-arm64@0.34.5': optional: true + '@img/sharp-win32-arm64@0.35.4': + optional: true + '@img/sharp-win32-ia32@0.34.5': optional: true + '@img/sharp-win32-ia32@0.35.4': + optional: true + '@img/sharp-win32-x64@0.34.5': optional: true + '@img/sharp-win32-x64@0.35.4': + optional: true + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -7449,11 +8824,13 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@mdx-js/mdx@3.1.1': + '@lukeed/csprng@1.1.0': {} + + '@mdx-js/mdx@3.1.1(supports-color@7.2.0)': dependencies: '@types/estree': 1.0.9 '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdx': 2.0.13 acorn: 8.16.0 collapse-white-space: 2.1.0 @@ -7461,14 +8838,14 @@ snapshots: estree-util-is-identifier-name: 3.0.0 estree-util-scope: 1.0.0 estree-walker: 3.0.3 - hast-util-to-jsx-runtime: 2.3.6 + hast-util-to-jsx-runtime: 2.3.6(supports-color@7.2.0) markdown-extensions: 2.0.0 recma-build-jsx: 1.0.0 recma-jsx: 1.0.1(acorn@8.16.0) recma-stringify: 1.0.0 - rehype-recma: 1.0.0 - remark-mdx: 3.1.1 - remark-parse: 11.0.0 + rehype-recma: 1.0.0(supports-color@7.2.0) + remark-mdx: 3.1.1(supports-color@7.2.0) + remark-parse: 11.0.0(supports-color@7.2.0) remark-rehype: 11.1.2 source-map: 0.7.6 unified: 11.0.5 @@ -7497,32 +8874,159 @@ snapshots: '@neodrag/core': 3.0.0-next.11 solid-js: 1.9.13 + '@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0)': + dependencies: + file-type: 21.3.4(supports-color@7.2.0) + iterare: 1.2.1 + load-esm: 1.0.3 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + transitivePeerDependencies: + - supports-color + + '@nestjs/core@11.2.3(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/platform-express@11.2.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0) + fast-safe-stringify: 2.1.1 + iterare: 1.2.1 + path-to-regexp: 8.4.2 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + optionalDependencies: + '@nestjs/platform-express': 11.2.3(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@11.2.3)(supports-color@7.2.0) + + '@nestjs/platform-express@11.2.3(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@11.2.3)(supports-color@7.2.0)': + dependencies: + '@nestjs/common': 11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0) + '@nestjs/core': 11.2.3(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/platform-express@11.2.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cors: 2.8.6 + express: 5.2.1(supports-color@7.2.0) + multer: 2.2.0 + path-to-regexp: 8.4.2 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@nestjs/testing@11.2.3(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@11.2.3)(@nestjs/platform-express@11.2.3)': + dependencies: + '@nestjs/common': 11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0) + '@nestjs/core': 11.2.3(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/platform-express@11.2.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + tslib: 2.8.1 + optionalDependencies: + '@nestjs/platform-express': 11.2.3(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@11.2.3)(supports-color@7.2.0) + + '@next/env@15.5.24': {} + + '@next/env@16.0.11': {} + '@next/env@16.2.6': {} + '@next/env@16.3.3': {} + + '@next/playwright@16.3.3(@playwright/test@1.62.1)': + optionalDependencies: + '@playwright/test': 1.62.1 + + '@next/swc-darwin-arm64@15.5.24': + optional: true + + '@next/swc-darwin-arm64@16.0.11': + optional: true + '@next/swc-darwin-arm64@16.2.6': optional: true + '@next/swc-darwin-arm64@16.3.3': + optional: true + + '@next/swc-darwin-x64@15.5.24': + optional: true + + '@next/swc-darwin-x64@16.0.11': + optional: true + '@next/swc-darwin-x64@16.2.6': optional: true + '@next/swc-darwin-x64@16.3.3': + optional: true + + '@next/swc-linux-arm64-gnu@15.5.24': + optional: true + + '@next/swc-linux-arm64-gnu@16.0.11': + optional: true + '@next/swc-linux-arm64-gnu@16.2.6': optional: true + '@next/swc-linux-arm64-gnu@16.3.3': + optional: true + + '@next/swc-linux-arm64-musl@15.5.24': + optional: true + + '@next/swc-linux-arm64-musl@16.0.11': + optional: true + '@next/swc-linux-arm64-musl@16.2.6': optional: true + '@next/swc-linux-arm64-musl@16.3.3': + optional: true + + '@next/swc-linux-x64-gnu@15.5.24': + optional: true + + '@next/swc-linux-x64-gnu@16.0.11': + optional: true + '@next/swc-linux-x64-gnu@16.2.6': optional: true + '@next/swc-linux-x64-gnu@16.3.3': + optional: true + + '@next/swc-linux-x64-musl@15.5.24': + optional: true + + '@next/swc-linux-x64-musl@16.0.11': + optional: true + '@next/swc-linux-x64-musl@16.2.6': optional: true + '@next/swc-linux-x64-musl@16.3.3': + optional: true + + '@next/swc-win32-arm64-msvc@15.5.24': + optional: true + + '@next/swc-win32-arm64-msvc@16.0.11': + optional: true + '@next/swc-win32-arm64-msvc@16.2.6': optional: true + '@next/swc-win32-arm64-msvc@16.3.3': + optional: true + + '@next/swc-win32-x64-msvc@15.5.24': + optional: true + + '@next/swc-win32-x64-msvc@16.0.11': + optional: true + '@next/swc-win32-x64-msvc@16.2.6': optional: true + '@next/swc-win32-x64-msvc@16.3.3': + optional: true + '@noble/hashes@1.8.0': {} '@nodelib/fs.scandir@2.1.5': @@ -7556,8 +9060,6 @@ snapshots: '@oozcitak/util@10.0.0': {} - '@orama/orama@3.1.18': {} - '@orpc/client@1.14.4': dependencies: '@orpc/shared': 1.14.4 @@ -7653,51 +9155,99 @@ snapshots: '@oxc-parser/binding-android-arm-eabi@0.120.0': optional: true + '@oxc-parser/binding-android-arm-eabi@0.147.0': + optional: true + '@oxc-parser/binding-android-arm64@0.120.0': optional: true + '@oxc-parser/binding-android-arm64@0.147.0': + optional: true + '@oxc-parser/binding-darwin-arm64@0.120.0': optional: true + '@oxc-parser/binding-darwin-arm64@0.147.0': + optional: true + '@oxc-parser/binding-darwin-x64@0.120.0': optional: true + '@oxc-parser/binding-darwin-x64@0.147.0': + optional: true + '@oxc-parser/binding-freebsd-x64@0.120.0': optional: true + '@oxc-parser/binding-freebsd-x64@0.147.0': + optional: true + '@oxc-parser/binding-linux-arm-gnueabihf@0.120.0': optional: true + '@oxc-parser/binding-linux-arm-gnueabihf@0.147.0': + optional: true + '@oxc-parser/binding-linux-arm-musleabihf@0.120.0': optional: true + '@oxc-parser/binding-linux-arm-musleabihf@0.147.0': + optional: true + '@oxc-parser/binding-linux-arm64-gnu@0.120.0': optional: true + '@oxc-parser/binding-linux-arm64-gnu@0.147.0': + optional: true + '@oxc-parser/binding-linux-arm64-musl@0.120.0': optional: true + '@oxc-parser/binding-linux-arm64-musl@0.147.0': + optional: true + '@oxc-parser/binding-linux-ppc64-gnu@0.120.0': optional: true + '@oxc-parser/binding-linux-ppc64-gnu@0.147.0': + optional: true + '@oxc-parser/binding-linux-riscv64-gnu@0.120.0': optional: true + '@oxc-parser/binding-linux-riscv64-gnu@0.147.0': + optional: true + '@oxc-parser/binding-linux-riscv64-musl@0.120.0': optional: true + '@oxc-parser/binding-linux-riscv64-musl@0.147.0': + optional: true + '@oxc-parser/binding-linux-s390x-gnu@0.120.0': optional: true + '@oxc-parser/binding-linux-s390x-gnu@0.147.0': + optional: true + '@oxc-parser/binding-linux-x64-gnu@0.120.0': optional: true + '@oxc-parser/binding-linux-x64-gnu@0.147.0': + optional: true + '@oxc-parser/binding-linux-x64-musl@0.120.0': optional: true + '@oxc-parser/binding-linux-x64-musl@0.147.0': + optional: true + '@oxc-parser/binding-openharmony-arm64@0.120.0': optional: true + '@oxc-parser/binding-openharmony-arm64@0.147.0': + optional: true + '@oxc-parser/binding-wasm32-wasi@0.120.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) @@ -7709,16 +9259,27 @@ snapshots: '@oxc-parser/binding-win32-arm64-msvc@0.120.0': optional: true + '@oxc-parser/binding-win32-arm64-msvc@0.147.0': + optional: true + '@oxc-parser/binding-win32-ia32-msvc@0.120.0': optional: true + '@oxc-parser/binding-win32-ia32-msvc@0.147.0': + optional: true + '@oxc-parser/binding-win32-x64-msvc@0.120.0': optional: true + '@oxc-parser/binding-win32-x64-msvc@0.147.0': + optional: true + '@oxc-project/types@0.120.0': {} '@oxc-project/types@0.133.0': {} + '@oxc-project/types@0.147.0': {} + '@oxfmt/binding-android-arm-eabi@0.64.0': optional: true @@ -7851,376 +9412,24 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.80.0': optional: true - '@paralleldrive/cuid2@2.3.1': - dependencies: - '@noble/hashes': 1.8.0 - - '@pinojs/redact@0.4.0': {} - - '@pkgjs/parseargs@0.11.0': - optional: true - - '@polka/url@1.0.0-next.29': {} - - '@quansync/fs@1.0.0': - dependencies: - quansync: 1.0.0 - - '@radix-ui/number@1.1.1': {} - - '@radix-ui/primitive@1.1.3': {} - - '@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.6) - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - optionalDependencies: - '@types/react': 19.2.15 - '@types/react-dom': 19.2.3(@types/react@19.2.15) - - '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - optionalDependencies: - '@types/react': 19.2.15 - '@types/react-dom': 19.2.3(@types/react@19.2.15) - - '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.6) - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - optionalDependencies: - '@types/react': 19.2.15 - '@types/react-dom': 19.2.3(@types/react@19.2.15) - - '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.15)(react@19.2.6) - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - optionalDependencies: - '@types/react': 19.2.15 - '@types/react-dom': 19.2.3(@types/react@19.2.15) - - '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.15)(react@19.2.6)': - dependencies: - react: 19.2.6 - optionalDependencies: - '@types/react': 19.2.15 - - '@radix-ui/react-context@1.1.2(@types/react@19.2.15)(react@19.2.6)': - dependencies: - react: 19.2.6 - optionalDependencies: - '@types/react': 19.2.15 - - '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.6) - aria-hidden: 1.2.6 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - react-remove-scroll: 2.7.2(@types/react@19.2.15)(react@19.2.6) - optionalDependencies: - '@types/react': 19.2.15 - '@types/react-dom': 19.2.3(@types/react@19.2.15) - - '@radix-ui/react-direction@1.1.1(@types/react@19.2.15)(react@19.2.6)': - dependencies: - react: 19.2.6 - optionalDependencies: - '@types/react': 19.2.15 - - '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.15)(react@19.2.6) - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - optionalDependencies: - '@types/react': 19.2.15 - '@types/react-dom': 19.2.3(@types/react@19.2.15) - - '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.15)(react@19.2.6)': - dependencies: - react: 19.2.6 - optionalDependencies: - '@types/react': 19.2.15 - - '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.15)(react@19.2.6) - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - optionalDependencies: - '@types/react': 19.2.15 - '@types/react-dom': 19.2.3(@types/react@19.2.15) - - '@radix-ui/react-id@1.1.1(@types/react@19.2.15)(react@19.2.6)': - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.6) - react: 19.2.6 - optionalDependencies: - '@types/react': 19.2.15 - - '@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - optionalDependencies: - '@types/react': 19.2.15 - '@types/react-dom': 19.2.3(@types/react@19.2.15) - - '@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.6) - aria-hidden: 1.2.6 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - react-remove-scroll: 2.7.2(@types/react@19.2.15)(react@19.2.6) - optionalDependencies: - '@types/react': 19.2.15 - '@types/react-dom': 19.2.3(@types/react@19.2.15) - - '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - '@floating-ui/react-dom': 2.1.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/rect': 1.1.1 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - optionalDependencies: - '@types/react': 19.2.15 - '@types/react-dom': 19.2.3(@types/react@19.2.15) - - '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.6) - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - optionalDependencies: - '@types/react': 19.2.15 - '@types/react-dom': 19.2.3(@types/react@19.2.15) - - '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.6) - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - optionalDependencies: - '@types/react': 19.2.15 - '@types/react-dom': 19.2.3(@types/react@19.2.15) - - '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.15)(react@19.2.6) - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - optionalDependencies: - '@types/react': 19.2.15 - '@types/react-dom': 19.2.3(@types/react@19.2.15) - - '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.6) - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - optionalDependencies: - '@types/react': 19.2.15 - '@types/react-dom': 19.2.3(@types/react@19.2.15) - - '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - '@radix-ui/number': 1.1.1 - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.6) - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - optionalDependencies: - '@types/react': 19.2.15 - '@types/react-dom': 19.2.3(@types/react@19.2.15) - - '@radix-ui/react-slot@1.2.3(@types/react@19.2.15)(react@19.2.6)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) - react: 19.2.6 - optionalDependencies: - '@types/react': 19.2.15 - - '@radix-ui/react-slot@1.2.4(@types/react@19.2.15)(react@19.2.6)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.15)(react@19.2.6) - react: 19.2.6 - optionalDependencies: - '@types/react': 19.2.15 - - '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.15)(react@19.2.6) - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - optionalDependencies: - '@types/react': 19.2.15 - '@types/react-dom': 19.2.3(@types/react@19.2.15) - - '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.15)(react@19.2.6)': - dependencies: - react: 19.2.6 - optionalDependencies: - '@types/react': 19.2.15 - - '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.15)(react@19.2.6)': - dependencies: - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.6) - react: 19.2.6 - optionalDependencies: - '@types/react': 19.2.15 - - '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.15)(react@19.2.6)': - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.6) - react: 19.2.6 - optionalDependencies: - '@types/react': 19.2.15 - - '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.15)(react@19.2.6)': - dependencies: - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.15)(react@19.2.6) - react: 19.2.6 - optionalDependencies: - '@types/react': 19.2.15 - - '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.15)(react@19.2.6)': - dependencies: - react: 19.2.6 - optionalDependencies: - '@types/react': 19.2.15 - - '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.15)(react@19.2.6)': + '@paralleldrive/cuid2@2.3.1': dependencies: - react: 19.2.6 - optionalDependencies: - '@types/react': 19.2.15 + '@noble/hashes': 1.8.0 - '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.15)(react@19.2.6)': - dependencies: - '@radix-ui/rect': 1.1.1 - react: 19.2.6 - optionalDependencies: - '@types/react': 19.2.15 + '@pinojs/redact@0.4.0': {} - '@radix-ui/react-use-size@1.1.1(@types/react@19.2.15)(react@19.2.6)': - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.15)(react@19.2.6) - react: 19.2.6 - optionalDependencies: - '@types/react': 19.2.15 + '@pkgjs/parseargs@0.11.0': + optional: true - '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@playwright/test@1.62.1': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - optionalDependencies: - '@types/react': 19.2.15 - '@types/react-dom': 19.2.3(@types/react@19.2.15) + playwright: 1.62.1 + + '@polka/url@1.0.0-next.29': {} - '@radix-ui/rect@1.1.1': {} + '@quansync/fs@1.0.0': + dependencies: + quansync: 1.0.0 '@remixicon/react@4.9.0(react@19.2.6)': dependencies: @@ -8279,55 +9488,61 @@ snapshots: '@sec-ant/readable-stream@0.4.1': {} - '@shikijs/core@4.1.0': + '@shikijs/core@4.4.3': dependencies: - '@shikijs/primitive': 4.1.0 - '@shikijs/types': 4.1.0 + '@shikijs/primitive': 4.4.3 + '@shikijs/types': 4.4.3 '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-to-html: 9.0.5 - '@shikijs/engine-javascript@4.1.0': + '@shikijs/engine-javascript@4.4.3': dependencies: - '@shikijs/types': 4.1.0 + '@shikijs/types': 4.4.3 '@shikijs/vscode-textmate': 10.0.2 oniguruma-to-es: 4.3.6 - '@shikijs/engine-oniguruma@4.1.0': + '@shikijs/engine-oniguruma@4.4.3': dependencies: - '@shikijs/types': 4.1.0 + '@shikijs/types': 4.4.3 '@shikijs/vscode-textmate': 10.0.2 - '@shikijs/langs@4.1.0': + '@shikijs/langs@4.4.3': dependencies: - '@shikijs/types': 4.1.0 + '@shikijs/types': 4.4.3 - '@shikijs/primitive@4.1.0': + '@shikijs/primitive@4.4.3': dependencies: - '@shikijs/types': 4.1.0 + '@shikijs/types': 4.4.3 '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 - '@shikijs/themes@4.1.0': + '@shikijs/themes@4.4.3': dependencies: - '@shikijs/types': 4.1.0 + '@shikijs/types': 4.4.3 - '@shikijs/twoslash@4.1.0(typescript@6.0.3)': + '@shikijs/twoslash@4.4.3(supports-color@7.2.0)(typescript@7.0.2)': dependencies: - '@shikijs/core': 4.1.0 - '@shikijs/types': 4.1.0 - twoslash: 0.3.8(typescript@6.0.3) - typescript: 6.0.3 + '@shikijs/core': 4.4.3 + '@shikijs/types': 4.4.3 + twoslash: 0.3.9(supports-color@7.2.0)(typescript@7.0.2) + typescript: 7.0.2 transitivePeerDependencies: - supports-color - '@shikijs/types@4.1.0': + '@shikijs/types@4.4.3': dependencies: '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@shikijs/vscode-textmate@10.0.2': {} + '@simple-libs/child-process-utils@1.0.2': + dependencies: + '@simple-libs/stream-utils': 1.2.0 + + '@simple-libs/stream-utils@1.2.0': {} + '@sinclair/typebox@0.34.49': {} '@sindresorhus/merge-streams@4.0.0': {} @@ -8377,15 +9592,15 @@ snapshots: dependencies: acorn: 8.16.0 - '@sveltejs/adapter-auto@7.0.1(@sveltejs/kit@2.61.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.0)(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))': + '@sveltejs/adapter-auto@7.0.1(@sveltejs/kit@2.61.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.0)(typescript@7.0.2)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))': dependencies: - '@sveltejs/kit': 2.61.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.0)(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + '@sveltejs/kit': 2.61.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.0)(typescript@7.0.2)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) - '@sveltejs/kit@2.61.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.0)(vite@8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.0)(typescript@6.0.3)(vite@8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': + '@sveltejs/kit@2.61.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.0)(typescript@7.0.2)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@standard-schema/spec': 1.1.0 '@sveltejs/acorn-typescript': 1.0.10(acorn@8.16.0) - '@sveltejs/vite-plugin-svelte': 7.1.2(svelte@5.56.0)(vite@8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + '@sveltejs/vite-plugin-svelte': 7.1.2(svelte@5.56.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@types/cookie': 0.6.0 acorn: 8.16.0 cookie: 0.6.0 @@ -8397,62 +9612,35 @@ snapshots: set-cookie-parser: 3.1.0 sirv: 3.0.2 svelte: 5.56.0 - vite: 8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) optionalDependencies: - typescript: 6.0.3 - optional: true - - '@sveltejs/kit@2.61.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.0)(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': - dependencies: - '@standard-schema/spec': 1.1.0 - '@sveltejs/acorn-typescript': 1.0.10(acorn@8.16.0) - '@sveltejs/vite-plugin-svelte': 7.1.2(svelte@5.56.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) - '@types/cookie': 0.6.0 - acorn: 8.16.0 - cookie: 0.6.0 - devalue: 5.8.1 - esm-env: 1.2.2 - kleur: 4.1.5 - magic-string: 0.30.21 - mrmime: 2.0.1 - set-cookie-parser: 3.1.0 - sirv: 3.0.2 - svelte: 5.56.0 - vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) - optionalDependencies: - typescript: 6.0.3 + typescript: 7.0.2 - '@sveltejs/package@2.5.7(svelte@5.56.0)(typescript@6.0.3)': + '@sveltejs/package@2.5.7(svelte@5.56.0)(typescript@7.0.2)': dependencies: chokidar: 5.0.0 kleur: 4.1.5 sade: 1.8.1 semver: 7.8.1 svelte: 5.56.0 - svelte2tsx: 0.7.55(svelte@5.56.0)(typescript@6.0.3) + svelte2tsx: 0.7.55(svelte@5.56.0)(typescript@7.0.2) transitivePeerDependencies: - typescript - '@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.0)(vite@8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': + '@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: deepmerge: 4.3.1 magic-string: 0.30.21 obug: 2.1.1 svelte: 5.56.0 - vite: 8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) - vitefu: 1.1.3(vite@8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) - optional: true + vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + vitefu: 1.1.3(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) - '@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': + '@swc/helpers@0.5.15': dependencies: - deepmerge: 4.3.1 - magic-string: 0.30.21 - obug: 2.1.1 - svelte: 5.56.0 - vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) - vitefu: 1.1.3(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + tslib: 2.8.1 - '@swc/helpers@0.5.15': + '@swc/helpers@0.5.23': dependencies: tslib: 2.8.1 @@ -8530,19 +9718,12 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 4.3.0 - '@tailwindcss/vite@4.3.0(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': - dependencies: - '@tailwindcss/node': 4.3.0 - '@tailwindcss/oxide': 4.3.0 - tailwindcss: 4.3.0 - vite: 8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) - - '@tailwindcss/vite@4.3.0(vite@8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': + '@tailwindcss/vite@4.3.0(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@tailwindcss/node': 4.3.0 '@tailwindcss/oxide': 4.3.0 tailwindcss: 4.3.0 - vite: 8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) '@tanstack/devtools-bundler-core@0.1.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: @@ -8581,13 +9762,13 @@ snapshots: transitivePeerDependencies: - csstype - '@tanstack/devtools-vite@0.8.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': + '@tanstack/devtools-vite@0.8.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@tanstack/devtools-bundler-core': 0.1.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) '@tanstack/devtools-client': 0.0.8 '@tanstack/devtools-event-bus': 0.4.3 chalk: 5.6.2 - vite: 8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -8623,7 +9804,7 @@ snapshots: semver: 7.8.1 yaml: 2.8.3 - '@tanstack/query-core@5.101.0': {} + '@tanstack/query-core@5.102.7': {} '@tanstack/react-devtools@0.10.12(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(csstype@3.2.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(solid-js@1.9.13)': dependencies: @@ -8638,9 +9819,9 @@ snapshots: - solid-js - utf-8-validate - '@tanstack/react-query@5.101.0(react@19.2.6)': + '@tanstack/react-query@5.102.7(react@19.2.6)': dependencies: - '@tanstack/query-core': 5.101.0 + '@tanstack/query-core': 5.102.7 react: 19.2.6 '@tanstack/react-router-devtools@1.167.0(@tanstack/react-router@1.170.15(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@tanstack/router-core@1.171.27)(csstype@3.2.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': @@ -8665,12 +9846,12 @@ snapshots: transitivePeerDependencies: - csstype - '@tanstack/react-router-ssr-query@1.167.1(@tanstack/query-core@5.101.0)(@tanstack/react-query@5.101.0(react@19.2.6))(@tanstack/react-router@1.170.32(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@tanstack/router-core@1.171.27)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@tanstack/react-router-ssr-query@1.167.2(@tanstack/query-core@5.102.7)(@tanstack/react-query@5.102.7(react@19.2.6))(@tanstack/react-router@1.170.32(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@tanstack/router-core@1.171.27)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@tanstack/query-core': 5.101.0 - '@tanstack/react-query': 5.101.0(react@19.2.6) + '@tanstack/query-core': 5.102.7 + '@tanstack/react-query': 5.102.7(react@19.2.6) '@tanstack/react-router': 1.170.32(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@tanstack/router-ssr-query-core': 1.169.1(@tanstack/query-core@5.101.0)(@tanstack/router-core@1.171.27) + '@tanstack/router-ssr-query-core': 1.169.2(@tanstack/query-core@5.102.7)(@tanstack/router-core@1.171.27) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) transitivePeerDependencies: @@ -8727,15 +9908,15 @@ snapshots: react: 19.2.6 react-dom: 19.2.6(react@19.2.6) - '@tanstack/react-start-rsc@0.1.17(crossws@0.4.5(srvx@0.11.16))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': + '@tanstack/react-start-rsc@0.1.17(crossws@0.4.5(srvx@0.11.16))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(supports-color@7.2.0)(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(supports-color@7.2.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@tanstack/react-router': 1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@tanstack/react-start-server': 1.167.13(crossws@0.4.5(srvx@0.11.16))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@tanstack/router-core': 1.171.8 - '@tanstack/router-utils': 1.162.1 + '@tanstack/router-utils': 1.162.1(supports-color@7.2.0) '@tanstack/start-client-core': 1.170.6 '@tanstack/start-fn-stubs': 1.162.0 - '@tanstack/start-plugin-core': 1.171.10(@tanstack/react-router@1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(crossws@0.4.5(srvx@0.11.16))(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + '@tanstack/start-plugin-core': 1.171.10(@tanstack/react-router@1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(crossws@0.4.5(srvx@0.11.16))(supports-color@7.2.0)(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(supports-color@7.2.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@tanstack/start-server-core': 1.169.8(crossws@0.4.5(srvx@0.11.16)) '@tanstack/start-storage-context': 1.167.10 pathe: 2.0.3 @@ -8749,14 +9930,14 @@ snapshots: - vite-plugin-solid - webpack - '@tanstack/react-start-rsc@0.1.23(crossws@0.4.5(srvx@0.11.16))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(vite@8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': + '@tanstack/react-start-rsc@0.1.23(crossws@0.4.5(srvx@0.11.16))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(supports-color@7.2.0)(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(supports-color@7.2.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@tanstack/react-router': 1.170.15(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@tanstack/router-core': 1.171.13 - '@tanstack/router-utils': 1.162.2 + '@tanstack/router-utils': 1.162.2(supports-color@7.2.0) '@tanstack/start-client-core': 1.170.11 '@tanstack/start-fn-stubs': 1.162.0 - '@tanstack/start-plugin-core': 1.171.16(@tanstack/react-router@1.170.15(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(crossws@0.4.5(srvx@0.11.16))(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(vite@8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + '@tanstack/start-plugin-core': 1.171.16(@tanstack/react-router@1.170.15(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(crossws@0.4.5(srvx@0.11.16))(supports-color@7.2.0)(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(supports-color@7.2.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@tanstack/start-server-core': 1.169.13(crossws@0.4.5(srvx@0.11.16)) '@tanstack/start-storage-context': 1.167.15 pathe: 2.0.3 @@ -8770,14 +9951,14 @@ snapshots: - vite-plugin-solid - webpack - '@tanstack/react-start-rsc@0.1.48(crossws@0.4.5(srvx@0.11.16))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': + '@tanstack/react-start-rsc@0.1.48(crossws@0.4.5(srvx@0.11.16))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(supports-color@7.2.0)(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(supports-color@7.2.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@tanstack/react-router': 1.170.32(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@tanstack/router-core': 1.171.27 - '@tanstack/router-utils': 1.162.2 + '@tanstack/router-utils': 1.162.2(supports-color@7.2.0) '@tanstack/start-client-core': 1.170.27 '@tanstack/start-fn-stubs': 1.162.0 - '@tanstack/start-plugin-core': 1.171.39(@tanstack/react-router@1.170.32(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(crossws@0.4.5(srvx@0.11.16))(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + '@tanstack/start-plugin-core': 1.171.39(@tanstack/react-router@1.170.32(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(crossws@0.4.5(srvx@0.11.16))(supports-color@7.2.0)(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(supports-color@7.2.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@tanstack/start-storage-context': 1.167.29 pathe: 2.0.3 react: 19.2.6 @@ -8822,21 +10003,21 @@ snapshots: transitivePeerDependencies: - crossws - '@tanstack/react-start@1.168.18(crossws@0.4.5(srvx@0.11.16))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': + '@tanstack/react-start@1.168.18(crossws@0.4.5(srvx@0.11.16))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(supports-color@7.2.0)(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(supports-color@7.2.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@tanstack/react-router': 1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@tanstack/react-start-client': 1.168.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@tanstack/react-start-rsc': 0.1.17(crossws@0.4.5(srvx@0.11.16))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + '@tanstack/react-start-rsc': 0.1.17(crossws@0.4.5(srvx@0.11.16))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(supports-color@7.2.0)(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(supports-color@7.2.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@tanstack/react-start-server': 1.167.13(crossws@0.4.5(srvx@0.11.16))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@tanstack/router-utils': 1.162.1 + '@tanstack/router-utils': 1.162.1(supports-color@7.2.0) '@tanstack/start-client-core': 1.170.6 - '@tanstack/start-plugin-core': 1.171.10(@tanstack/react-router@1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(crossws@0.4.5(srvx@0.11.16))(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + '@tanstack/start-plugin-core': 1.171.10(@tanstack/react-router@1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(crossws@0.4.5(srvx@0.11.16))(supports-color@7.2.0)(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(supports-color@7.2.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@tanstack/start-server-core': 1.169.8(crossws@0.4.5(srvx@0.11.16)) pathe: 2.0.3 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) optionalDependencies: - vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) transitivePeerDependencies: - '@rspack/core' - crossws @@ -8845,21 +10026,21 @@ snapshots: - vite-plugin-solid - webpack - '@tanstack/react-start@1.168.24(crossws@0.4.5(srvx@0.11.16))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(vite@8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': + '@tanstack/react-start@1.168.24(crossws@0.4.5(srvx@0.11.16))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(supports-color@7.2.0)(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(supports-color@7.2.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@tanstack/react-router': 1.170.15(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@tanstack/react-start-client': 1.168.12(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@tanstack/react-start-rsc': 0.1.23(crossws@0.4.5(srvx@0.11.16))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(vite@8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + '@tanstack/react-start-rsc': 0.1.23(crossws@0.4.5(srvx@0.11.16))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(supports-color@7.2.0)(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(supports-color@7.2.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@tanstack/react-start-server': 1.167.18(crossws@0.4.5(srvx@0.11.16))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@tanstack/router-utils': 1.162.2 + '@tanstack/router-utils': 1.162.2(supports-color@7.2.0) '@tanstack/start-client-core': 1.170.11 - '@tanstack/start-plugin-core': 1.171.16(@tanstack/react-router@1.170.15(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(crossws@0.4.5(srvx@0.11.16))(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(vite@8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + '@tanstack/start-plugin-core': 1.171.16(@tanstack/react-router@1.170.15(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(crossws@0.4.5(srvx@0.11.16))(supports-color@7.2.0)(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(supports-color@7.2.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@tanstack/start-server-core': 1.169.13(crossws@0.4.5(srvx@0.11.16)) pathe: 2.0.3 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) optionalDependencies: - vite: 8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) transitivePeerDependencies: - '@rspack/core' - crossws @@ -8868,21 +10049,21 @@ snapshots: - vite-plugin-solid - webpack - '@tanstack/react-start@1.168.49(crossws@0.4.5(srvx@0.11.16))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': + '@tanstack/react-start@1.168.49(crossws@0.4.5(srvx@0.11.16))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(supports-color@7.2.0)(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(supports-color@7.2.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@tanstack/react-router': 1.170.32(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@tanstack/react-start-client': 1.168.30(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@tanstack/react-start-rsc': 0.1.48(crossws@0.4.5(srvx@0.11.16))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + '@tanstack/react-start-rsc': 0.1.48(crossws@0.4.5(srvx@0.11.16))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(supports-color@7.2.0)(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(supports-color@7.2.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@tanstack/react-start-server': 1.167.37(crossws@0.4.5(srvx@0.11.16))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@tanstack/router-utils': 1.162.2 + '@tanstack/router-utils': 1.162.2(supports-color@7.2.0) '@tanstack/start-client-core': 1.170.27 - '@tanstack/start-plugin-core': 1.171.39(@tanstack/react-router@1.170.32(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(crossws@0.4.5(srvx@0.11.16))(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + '@tanstack/start-plugin-core': 1.171.39(@tanstack/react-router@1.170.32(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(crossws@0.4.5(srvx@0.11.16))(supports-color@7.2.0)(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(supports-color@7.2.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@tanstack/start-server-core': 1.169.31(crossws@0.4.5(srvx@0.11.16)) pathe: 2.0.3 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) optionalDependencies: - vite: 8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) transitivePeerDependencies: - '@rspack/core' - crossws @@ -8898,9 +10079,9 @@ snapshots: react-dom: 19.2.6(react@19.2.6) use-sync-external-store: 1.6.0(react@19.2.6) - '@tanstack/router-cli@1.167.17': + '@tanstack/router-cli@1.167.17(supports-color@7.2.0)': dependencies: - '@tanstack/router-generator': 1.167.17 + '@tanstack/router-generator': 1.167.17(supports-color@7.2.0) chokidar: 5.0.0 yargs: 17.7.2 transitivePeerDependencies: @@ -8943,11 +10124,11 @@ snapshots: optionalDependencies: csstype: 3.2.3 - '@tanstack/router-generator@1.167.12': + '@tanstack/router-generator@1.167.12(supports-color@7.2.0)': dependencies: '@babel/types': 7.29.7 '@tanstack/router-core': 1.171.8 - '@tanstack/router-utils': 1.162.1 + '@tanstack/router-utils': 1.162.1(supports-color@7.2.0) '@tanstack/virtual-file-routes': 1.162.0 jiti: 2.7.0 magic-string: 0.30.21 @@ -8956,11 +10137,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-generator@1.167.17': + '@tanstack/router-generator@1.167.17(supports-color@7.2.0)': dependencies: '@babel/types': 7.29.7 '@tanstack/router-core': 1.171.13 - '@tanstack/router-utils': 1.162.2 + '@tanstack/router-utils': 1.162.2(supports-color@7.2.0) '@tanstack/virtual-file-routes': 1.162.0 jiti: 2.7.0 magic-string: 0.30.21 @@ -8969,11 +10150,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-generator@1.167.33': + '@tanstack/router-generator@1.167.33(supports-color@7.2.0)': dependencies: '@babel/types': 7.29.7 '@tanstack/router-core': 1.171.27 - '@tanstack/router-utils': 1.162.2 + '@tanstack/router-utils': 1.162.2(supports-color@7.2.0) '@tanstack/virtual-file-routes': 1.162.0 jiti: 2.7.0 magic-string: 0.30.21 @@ -8982,112 +10163,112 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.168.13(@tanstack/react-router@1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': + '@tanstack/router-plugin@1.168.13(@tanstack/react-router@1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(supports-color@7.2.0)(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(supports-color@7.2.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: - '@babel/core': 7.29.7 - '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@7.2.0) '@babel/types': 7.29.7 '@tanstack/router-core': 1.171.8 - '@tanstack/router-generator': 1.167.12 - '@tanstack/router-utils': 1.162.1 + '@tanstack/router-generator': 1.167.12(supports-color@7.2.0) + '@tanstack/router-utils': 1.162.1(supports-color@7.2.0) '@tanstack/virtual-file-routes': 1.162.0 chokidar: 5.0.0 unplugin: 3.0.0 zod: 4.4.3 optionalDependencies: '@tanstack/react-router': 1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) - vite-plugin-solid: 2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + vite-plugin-solid: 2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(supports-color@7.2.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.168.13(@tanstack/react-router@1.170.32(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': + '@tanstack/router-plugin@1.168.13(@tanstack/react-router@1.170.32(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(supports-color@7.2.0)(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(supports-color@7.2.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: - '@babel/core': 7.29.7 - '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@7.2.0) '@babel/types': 7.29.7 '@tanstack/router-core': 1.171.8 - '@tanstack/router-generator': 1.167.12 - '@tanstack/router-utils': 1.162.1 + '@tanstack/router-generator': 1.167.12(supports-color@7.2.0) + '@tanstack/router-utils': 1.162.1(supports-color@7.2.0) '@tanstack/virtual-file-routes': 1.162.0 chokidar: 5.0.0 unplugin: 3.0.0 zod: 4.4.3 optionalDependencies: '@tanstack/react-router': 1.170.32(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - vite: 8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) - vite-plugin-solid: 2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + vite-plugin-solid: 2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(supports-color@7.2.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.168.18(@tanstack/react-router@1.170.15(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(vite@8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': + '@tanstack/router-plugin@1.168.18(@tanstack/react-router@1.170.15(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(supports-color@7.2.0)(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(supports-color@7.2.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/template': 7.29.7 '@babel/types': 7.29.7 '@tanstack/router-core': 1.171.13 - '@tanstack/router-generator': 1.167.17 - '@tanstack/router-utils': 1.162.2 + '@tanstack/router-generator': 1.167.17(supports-color@7.2.0) + '@tanstack/router-utils': 1.162.2(supports-color@7.2.0) chokidar: 5.0.0 unplugin: 3.0.0 zod: 4.4.3 optionalDependencies: '@tanstack/react-router': 1.170.15(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - vite: 8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) - vite-plugin-solid: 2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(vite@8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + vite-plugin-solid: 2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(supports-color@7.2.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.168.35(@tanstack/react-router@1.170.32(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': + '@tanstack/router-plugin@1.168.35(@tanstack/react-router@1.170.32(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(supports-color@7.2.0)(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(supports-color@7.2.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/template': 7.29.7 '@babel/types': 7.29.7 '@tanstack/router-core': 1.171.27 - '@tanstack/router-generator': 1.167.33 - '@tanstack/router-utils': 1.162.2 + '@tanstack/router-generator': 1.167.33(supports-color@7.2.0) + '@tanstack/router-utils': 1.162.2(supports-color@7.2.0) chokidar: 5.0.0 unplugin: 3.0.0 zod: 4.4.3 optionalDependencies: '@tanstack/react-router': 1.170.32(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - vite: 8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) - vite-plugin-solid: 2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + vite-plugin-solid: 2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(supports-color@7.2.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) transitivePeerDependencies: - supports-color - '@tanstack/router-ssr-query-core@1.169.1(@tanstack/query-core@5.101.0)(@tanstack/router-core@1.171.27)': + '@tanstack/router-ssr-query-core@1.169.2(@tanstack/query-core@5.102.7)(@tanstack/router-core@1.171.27)': dependencies: - '@tanstack/query-core': 5.101.0 + '@tanstack/query-core': 5.102.7 '@tanstack/router-core': 1.171.27 - '@tanstack/router-utils@1.162.1': + '@tanstack/router-utils@1.162.1(supports-color@7.2.0)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/generator': 7.29.7 '@babel/parser': 7.29.7 '@babel/types': 7.29.7 ansis: 4.3.0 - babel-dead-code-elimination: 1.0.12 + babel-dead-code-elimination: 1.0.12(supports-color@7.2.0) diff: 8.0.4 pathe: 2.0.3 tinyglobby: 0.2.17 transitivePeerDependencies: - supports-color - '@tanstack/router-utils@1.162.2': + '@tanstack/router-utils@1.162.2(supports-color@7.2.0)': dependencies: '@babel/generator': 7.29.7 '@babel/parser': 7.29.7 '@babel/types': 7.29.7 ansis: 4.3.0 - babel-dead-code-elimination: 1.0.12 + babel-dead-code-elimination: 1.0.12(supports-color@7.2.0) diff: 8.0.4 pathe: 2.0.3 tinyglobby: 0.2.17 @@ -9117,32 +10298,32 @@ snapshots: '@tanstack/start-fn-stubs@1.162.0': {} - '@tanstack/start-plugin-core@1.171.10(@tanstack/react-router@1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(crossws@0.4.5(srvx@0.11.16))(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': + '@tanstack/start-plugin-core@1.171.10(@tanstack/react-router@1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(crossws@0.4.5(srvx@0.11.16))(supports-color@7.2.0)(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(supports-color@7.2.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@babel/code-frame': 7.27.1 - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/types': 7.29.7 '@rolldown/pluginutils': 1.0.1 '@tanstack/router-core': 1.171.8 - '@tanstack/router-generator': 1.167.12 - '@tanstack/router-plugin': 1.168.13(@tanstack/react-router@1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) - '@tanstack/router-utils': 1.162.1 + '@tanstack/router-generator': 1.167.12(supports-color@7.2.0) + '@tanstack/router-plugin': 1.168.13(@tanstack/react-router@1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(supports-color@7.2.0)(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(supports-color@7.2.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + '@tanstack/router-utils': 1.162.1(supports-color@7.2.0) '@tanstack/start-client-core': 1.170.6 '@tanstack/start-server-core': 1.169.8(crossws@0.4.5(srvx@0.11.16)) exsolve: 1.0.8 lightningcss: 1.32.0 pathe: 2.0.3 - picomatch: 4.0.4 + picomatch: 4.0.7 seroval: 1.5.4 source-map: 0.7.6 srvx: 0.11.16 tinyglobby: 0.2.17 ufo: 1.6.4 - vitefu: 1.1.3(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + vitefu: 1.1.3(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) xmlbuilder2: 4.0.3 zod: 4.4.3 optionalDependencies: - vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) transitivePeerDependencies: - '@tanstack/react-router' - crossws @@ -9150,30 +10331,30 @@ snapshots: - vite-plugin-solid - webpack - '@tanstack/start-plugin-core@1.171.16(@tanstack/react-router@1.170.15(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(crossws@0.4.5(srvx@0.11.16))(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(vite@8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': + '@tanstack/start-plugin-core@1.171.16(@tanstack/react-router@1.170.15(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(crossws@0.4.5(srvx@0.11.16))(supports-color@7.2.0)(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(supports-color@7.2.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@babel/code-frame': 7.27.1 - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/types': 7.29.7 '@tanstack/router-core': 1.171.13 - '@tanstack/router-generator': 1.167.17 - '@tanstack/router-plugin': 1.168.18(@tanstack/react-router@1.170.15(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(vite@8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) - '@tanstack/router-utils': 1.162.2 + '@tanstack/router-generator': 1.167.17(supports-color@7.2.0) + '@tanstack/router-plugin': 1.168.18(@tanstack/react-router@1.170.15(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(supports-color@7.2.0)(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(supports-color@7.2.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + '@tanstack/router-utils': 1.162.2(supports-color@7.2.0) '@tanstack/start-server-core': 1.169.13(crossws@0.4.5(srvx@0.11.16)) exsolve: 1.0.8 lightningcss: 1.32.0 pathe: 2.0.3 - picomatch: 4.0.4 + picomatch: 4.0.7 seroval: 1.5.4 source-map: 0.7.6 srvx: 0.11.16 tinyglobby: 0.2.17 ufo: 1.6.4 - vitefu: 1.1.3(vite@8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + vitefu: 1.1.3(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) xmlbuilder2: 4.0.3 zod: 4.4.3 optionalDependencies: - vite: 8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) transitivePeerDependencies: - '@tanstack/react-router' - crossws @@ -9181,30 +10362,30 @@ snapshots: - vite-plugin-solid - webpack - '@tanstack/start-plugin-core@1.171.39(@tanstack/react-router@1.170.32(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(crossws@0.4.5(srvx@0.11.16))(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': + '@tanstack/start-plugin-core@1.171.39(@tanstack/react-router@1.170.32(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(crossws@0.4.5(srvx@0.11.16))(supports-color@7.2.0)(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(supports-color@7.2.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@babel/code-frame': 7.27.1 - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/types': 7.29.7 '@tanstack/router-core': 1.171.27 - '@tanstack/router-generator': 1.167.33 - '@tanstack/router-plugin': 1.168.35(@tanstack/react-router@1.170.32(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) - '@tanstack/router-utils': 1.162.2 + '@tanstack/router-generator': 1.167.33(supports-color@7.2.0) + '@tanstack/router-plugin': 1.168.35(@tanstack/react-router@1.170.32(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(supports-color@7.2.0)(vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(supports-color@7.2.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + '@tanstack/router-utils': 1.162.2(supports-color@7.2.0) '@tanstack/start-server-core': 1.169.31(crossws@0.4.5(srvx@0.11.16)) exsolve: 1.0.8 lightningcss: 1.32.0 pathe: 2.0.3 - picomatch: 4.0.4 + picomatch: 4.0.7 seroval: 1.6.4 source-map: 0.7.6 srvx: 0.11.16 tinyglobby: 0.2.17 ufo: 1.6.4 - vitefu: 1.1.3(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + vitefu: 1.1.3(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) xmlbuilder2: 4.0.3 zod: 4.4.3 optionalDependencies: - vite: 8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) transitivePeerDependencies: - '@tanstack/react-router' - crossws @@ -9298,49 +10479,49 @@ snapshots: dependencies: svelte: 5.56.0 - '@testing-library/svelte@5.3.1(svelte@5.56.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(vitest@4.1.8)': + '@testing-library/svelte@5.3.1(svelte@5.56.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(vitest@4.1.8)': dependencies: '@testing-library/dom': 10.4.1 '@testing-library/svelte-core': 1.0.0(svelte@5.56.0) svelte: 5.56.0 optionalDependencies: - vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) - vitest: 4.1.8(@types/node@25.9.1)(@vitest/coverage-v8@4.1.8)(happy-dom@20.9.0)(jsdom@28.1.0(@noble/hashes@1.8.0))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + vitest: 4.1.8(@types/node@25.9.1)(@vitest/coverage-v8@4.1.8)(happy-dom@20.9.0)(jsdom@28.1.0(@noble/hashes@1.8.0)(supports-color@7.2.0))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) - '@tokenizer/inflate@0.4.1': + '@tokenizer/inflate@0.4.1(supports-color@7.2.0)': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) token-types: 6.1.2 transitivePeerDependencies: - supports-color '@tokenizer/token@0.3.0': {} - '@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@6.0.3))(typescript@6.0.3)': + '@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@7.0.2))(typescript@7.0.2)': dependencies: - '@trpc/server': 11.17.0(typescript@6.0.3) - typescript: 6.0.3 + '@trpc/server': 11.17.0(typescript@7.0.2) + typescript: 7.0.2 - '@trpc/server@11.17.0(typescript@6.0.3)': + '@trpc/server@11.17.0(typescript@7.0.2)': dependencies: - typescript: 6.0.3 + typescript: 7.0.2 - '@turbo/darwin-64@2.9.16': + '@turbo/darwin-64@2.10.12': optional: true - '@turbo/darwin-arm64@2.9.16': + '@turbo/darwin-arm64@2.10.12': optional: true - '@turbo/linux-64@2.9.16': + '@turbo/linux-64@2.10.12': optional: true - '@turbo/linux-arm64@2.9.16': + '@turbo/linux-arm64@2.10.12': optional: true - '@turbo/windows-64@2.9.16': + '@turbo/windows-64@2.10.12': optional: true - '@turbo/windows-arm64@2.9.16': + '@turbo/windows-arm64@2.10.12': optional: true '@tybys/wasm-util@0.10.2': @@ -9541,6 +10722,10 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/hast@3.0.5': + dependencies: + '@types/unist': 3.0.3 + '@types/http-errors@2.0.5': {} '@types/jsesc@2.5.1': {} @@ -9555,14 +10740,6 @@ snapshots: '@types/ms@2.1.0': {} - '@types/node@22.19.20': - dependencies: - undici-types: 6.21.0 - - '@types/node@24.10.0': - dependencies: - undici-types: 7.16.0 - '@types/node@25.9.1': dependencies: undici-types: 7.24.6 @@ -9618,10 +10795,74 @@ snapshots: dependencies: '@types/node': 25.9.1 - '@typescript/vfs@1.6.4(typescript@6.0.3)': + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + '@typescript/typescript6@6.0.2': + dependencies: + '@typescript/old': typescript@6.0.3 + + '@typescript/vfs@1.6.4(supports-color@7.2.0)(typescript@7.0.2)': dependencies: - debug: 4.4.3 - typescript: 6.0.3 + debug: 4.4.3(supports-color@7.2.0) + typescript: 7.0.2 transitivePeerDependencies: - supports-color @@ -9632,34 +10873,24 @@ snapshots: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) - '@vercel/analytics@2.0.1(@sveltejs/kit@2.61.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.0)(vite@8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.0)(typescript@6.0.3)(vite@8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(svelte@5.56.0)(vue@3.5.35(typescript@6.0.3))': + '@vercel/analytics@2.0.1(@sveltejs/kit@2.61.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.0)(typescript@7.0.2)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(next@16.3.3(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.62.1)(@types/node@25.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(svelte@5.56.0)(vue@3.5.35(typescript@7.0.2))': optionalDependencies: - '@sveltejs/kit': 2.61.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.0)(vite@8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.0)(typescript@6.0.3)(vite@8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) - next: 16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@sveltejs/kit': 2.61.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.0)(typescript@7.0.2)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + next: 16.3.3(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.62.1)(@types/node@25.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: 19.2.6 svelte: 5.56.0 - vue: 3.5.35(typescript@6.0.3) - - '@vitejs/plugin-react@6.0.2(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': - dependencies: - '@rolldown/pluginutils': 1.0.1 - vite: 8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) - - '@vitejs/plugin-react@6.0.2(vite@8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': - dependencies: - '@rolldown/pluginutils': 1.0.1 - vite: 8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + vue: 3.5.35(typescript@7.0.2) - '@vitejs/plugin-react@6.0.2(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': + '@vitejs/plugin-react@6.0.2(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) - '@vitejs/plugin-vue@6.0.7(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(vue@3.5.35(typescript@6.0.3))': + '@vitejs/plugin-vue@6.0.7(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))(vue@3.5.35(typescript@7.0.2))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) - vue: 3.5.35(typescript@6.0.3) + vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + vue: 3.5.35(typescript@7.0.2) '@vitest/coverage-v8@4.1.8(vitest@4.1.8)': dependencies: @@ -9669,11 +10900,11 @@ snapshots: istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 istanbul-reports: 3.2.0 - magicast: 0.5.3 + magicast: 0.5.4 obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.8(@types/node@25.9.1)(@vitest/coverage-v8@4.1.8)(happy-dom@20.9.0)(jsdom@28.1.0(@noble/hashes@1.8.0))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + vitest: 4.1.8(@types/node@25.9.1)(@vitest/coverage-v8@4.1.8)(happy-dom@20.9.0)(jsdom@28.1.0(@noble/hashes@1.8.0)(supports-color@7.2.0))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/expect@4.1.8': dependencies: @@ -9684,21 +10915,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.8(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': - dependencies: - '@vitest/spy': 4.1.8 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) - - '@vitest/mocker@4.1.8(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': + '@vitest/mocker@4.1.8(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.8 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) '@vitest/pretty-format@4.1.8': dependencies: @@ -9730,11 +10953,13 @@ snapshots: '@volar/source-map@2.4.28': {} - '@volar/typescript@2.4.28': + '@volar/typescript@2.4.28(typescript@7.0.2)': dependencies: '@volar/language-core': 2.4.28 path-browserify: 1.0.1 vscode-uri: 3.1.0 + optionalDependencies: + typescript: 7.0.2 '@vue/compiler-core@3.5.35': dependencies: @@ -9758,7 +10983,7 @@ snapshots: '@vue/shared': 3.5.35 estree-walker: 2.0.2 magic-string: 0.30.21 - postcss: 8.5.15 + postcss: 8.5.23 source-map-js: 1.2.1 '@vue/compiler-ssr@3.5.35': @@ -9774,7 +10999,7 @@ snapshots: alien-signals: 3.2.1 muggle-string: 0.4.1 path-browserify: 1.0.1 - picomatch: 4.0.4 + picomatch: 4.0.7 '@vue/reactivity@3.5.35': dependencies: @@ -9792,27 +11017,65 @@ snapshots: '@vue/shared': 3.5.35 csstype: 3.2.3 - '@vue/server-renderer@3.5.35(vue@3.5.35(typescript@6.0.3))': + '@vue/server-renderer@3.5.35(vue@3.5.35(typescript@7.0.2))': dependencies: '@vue/compiler-ssr': 3.5.35 '@vue/shared': 3.5.35 - vue: 3.5.35(typescript@6.0.3) + vue: 3.5.35(typescript@7.0.2) '@vue/shared@3.5.35': {} - '@vue/test-utils@2.4.10(@vue/compiler-dom@3.5.35)(@vue/server-renderer@3.5.35(vue@3.5.35(typescript@6.0.3)))(vue@3.5.35(typescript@6.0.3))': + '@vue/test-utils@2.4.10(@vue/compiler-dom@3.5.35)(@vue/server-renderer@3.5.35(vue@3.5.35(typescript@7.0.2)))(vue@3.5.35(typescript@7.0.2))': dependencies: '@vue/compiler-dom': 3.5.35 js-beautify: 1.15.4 - vue: 3.5.35(typescript@6.0.3) + vue: 3.5.35(typescript@7.0.2) vue-component-type-helpers: 3.3.3 optionalDependencies: - '@vue/server-renderer': 3.5.35(vue@3.5.35(typescript@6.0.3)) + '@vue/server-renderer': 3.5.35(vue@3.5.35(typescript@7.0.2)) - '@vue/tsconfig@0.9.1(typescript@6.0.3)(vue@3.5.35(typescript@6.0.3))': + '@vue/tsconfig@0.9.1(typescript@7.0.2)(vue@3.5.35(typescript@7.0.2))': optionalDependencies: - typescript: 6.0.3 - vue: 3.5.35(typescript@6.0.3) + typescript: 7.0.2 + vue: 3.5.35(typescript@7.0.2) + + '@yuku-analyzer/binding-android-arm64@0.9.3': + optional: true + + '@yuku-analyzer/binding-darwin-arm64@0.9.3': + optional: true + + '@yuku-analyzer/binding-darwin-x64@0.9.3': + optional: true + + '@yuku-analyzer/binding-freebsd-x64@0.9.3': + optional: true + + '@yuku-analyzer/binding-linux-arm-gnu@0.9.3': + optional: true + + '@yuku-analyzer/binding-linux-arm-musl@0.9.3': + optional: true + + '@yuku-analyzer/binding-linux-arm64-gnu@0.9.3': + optional: true + + '@yuku-analyzer/binding-linux-arm64-musl@0.9.3': + optional: true + + '@yuku-analyzer/binding-linux-x64-gnu@0.9.3': + optional: true + + '@yuku-analyzer/binding-linux-x64-musl@0.9.3': + optional: true + + '@yuku-analyzer/binding-win32-arm64@0.9.3': + optional: true + + '@yuku-analyzer/binding-win32-x64@0.9.3': + optional: true + + '@yuku-toolchain/types@0.9.3': {} abbrev@2.0.0: {} @@ -9862,11 +11125,9 @@ snapshots: ansis@4.3.0: {} - argparse@2.0.1: {} + append-field@1.0.0: {} - aria-hidden@1.2.6: - dependencies: - tslib: 2.8.1 + argparse@2.0.1: {} aria-query@5.3.0: dependencies: @@ -9876,6 +11137,18 @@ snapshots: aria-query@5.3.2: {} + arkregex@0.0.8: + dependencies: + '@ark/util': 0.56.2 + + arktype@2.2.3: + dependencies: + '@ark/schema': 0.56.2 + '@ark/util': 0.56.2 + arkregex: 0.0.8 + + array-ify@1.0.0: {} + asap@2.0.6: {} assertion-error@2.0.1: {} @@ -9905,28 +11178,28 @@ snapshots: axobject-query@4.1.0: {} - babel-dead-code-elimination@1.0.12: + babel-dead-code-elimination@1.0.12(supports-color@7.2.0): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/parser': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@7.2.0) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - babel-plugin-jsx-dom-expressions@0.40.7(@babel/core@7.29.7): + babel-plugin-jsx-dom-expressions@0.40.7(@babel/core@7.29.7(supports-color@7.2.0)): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/helper-module-imports': 7.18.6 - '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) '@babel/types': 7.29.7 html-entities: 2.3.3 parse5: 7.3.0 - babel-preset-solid@1.9.12(@babel/core@7.29.7)(solid-js@1.9.13): + babel-preset-solid@1.9.12(@babel/core@7.29.7(supports-color@7.2.0))(solid-js@1.9.13): dependencies: - '@babel/core': 7.29.7 - babel-plugin-jsx-dom-expressions: 0.40.7(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@7.2.0) + babel-plugin-jsx-dom-expressions: 0.40.7(@babel/core@7.29.7(supports-color@7.2.0)) optionalDependencies: solid-js: 1.9.13 @@ -9942,11 +11215,11 @@ snapshots: birpc@4.0.0: {} - body-parser@2.2.2: + body-parser@2.2.2(supports-color@7.2.0): dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 @@ -9972,6 +11245,12 @@ snapshots: node-releases: 2.0.46 update-browserslist-db: 1.2.3(browserslist@4.28.2) + buffer-from@1.1.2: {} + + busboy@1.6.0: + dependencies: + streamsearch: 1.1.0 + bytes@3.1.2: {} cac@6.7.14: {} @@ -9988,6 +11267,8 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 + callsites@3.1.0: {} + caniuse-lite@1.0.30001793: {} ccount@2.0.1: {} @@ -10037,6 +11318,10 @@ snapshots: clsx@2.1.1: {} + cnfast@0.0.8: {} + + cnfast@0.1.0: {} + collapse-white-space@2.1.0: {} color-convert@2.0.1: @@ -10059,10 +11344,22 @@ snapshots: commander@8.3.0: {} + compare-func@2.0.0: + dependencies: + array-ify: 1.0.0 + dot-prop: 5.3.0 + component-emitter@1.3.1: {} compute-scroll-into-view@3.1.1: {} + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + confbox@0.1.8: {} config-chain@1.1.13: @@ -10078,8 +11375,23 @@ snapshots: content-type@2.0.0: {} + conventional-changelog-angular@8.3.1: + dependencies: + compare-func: 2.0.0 + + conventional-changelog-conventionalcommits@9.3.1: + dependencies: + compare-func: 2.0.0 + + conventional-commits-parser@6.4.0: + dependencies: + '@simple-libs/stream-utils': 1.2.0 + meow: 13.2.0 + convert-source-map@2.0.0: {} + cookie-es@1.2.3: {} + cookie-es@3.1.1: {} cookie-signature@1.2.2: {} @@ -10105,12 +11417,32 @@ snapshots: dependencies: layout-base: 2.0.1 + cosmiconfig-typescript-loader@6.3.0(@types/node@25.9.1)(cosmiconfig@9.0.2(typescript@7.0.2))(typescript@7.0.2): + dependencies: + '@types/node': 25.9.1 + cosmiconfig: 9.0.2(typescript@7.0.2) + jiti: 2.6.1 + typescript: 7.0.2 + + cosmiconfig@9.0.2(typescript@7.0.2): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + parse-json: 5.2.0 + optionalDependencies: + typescript: 7.0.2 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 shebang-command: 2.0.0 which: 2.0.2 + crossws@0.3.5: + dependencies: + uncrypto: 0.1.3 + crossws@0.4.5(srvx@0.11.16): optionalDependencies: srvx: 0.11.16 @@ -10326,13 +11658,15 @@ snapshots: dayjs@1.11.21: {} - db0@0.3.4(drizzle-orm@1.0.0-rc.3(@sinclair/typebox@0.34.49)(@types/pg@8.20.0)(zod@4.4.3)): + db0@0.3.4(drizzle-orm@1.0.0-rc.3(@sinclair/typebox@0.34.49)(@types/pg@8.20.0)(arktype@2.2.3)(valibot@1.4.2(typescript@7.0.2))(zod@4.4.3)): optionalDependencies: - drizzle-orm: 1.0.0-rc.3(@sinclair/typebox@0.34.49)(@types/pg@8.20.0)(effect@3.21.2)(zod@4.4.3) + drizzle-orm: 1.0.0-rc.3(@sinclair/typebox@0.34.49)(@types/pg@8.20.0)(arktype@2.2.3)(effect@3.21.2)(valibot@1.4.2(typescript@7.0.2))(zod@4.4.3) - debug@4.4.3: + debug@4.4.3(supports-color@7.2.0): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 7.2.0 decimal.js@10.6.0: {} @@ -10383,11 +11717,17 @@ snapshots: optionalDependencies: '@types/trusted-types': 2.0.7 - drizzle-orm@1.0.0-rc.3(@sinclair/typebox@0.34.49)(@types/pg@8.20.0)(effect@3.21.2)(zod@4.4.3): + dot-prop@5.3.0: + dependencies: + is-obj: 2.0.0 + + drizzle-orm@1.0.0-rc.3(@sinclair/typebox@0.34.49)(@types/pg@8.20.0)(arktype@2.2.3)(effect@3.21.2)(valibot@1.4.2(typescript@7.0.2))(zod@4.4.3): optionalDependencies: '@sinclair/typebox': 0.34.49 '@types/pg': 8.20.0 + arktype: 2.2.3 effect: 3.21.2 + valibot: 1.4.2(typescript@7.0.2) zod: 4.4.3 dts-resolver@3.0.0: {} @@ -10416,17 +11756,17 @@ snapshots: electron-to-chromium@1.5.364: {} - elysia@1.4.28(@sinclair/typebox@0.34.49)(exact-mirror@1.0.0)(file-type@22.0.1)(openapi-types@12.1.3)(typescript@6.0.3): + elysia@1.4.28(@sinclair/typebox@0.34.49)(exact-mirror@1.0.0)(file-type@22.0.1(supports-color@7.2.0))(openapi-types@12.1.3)(typescript@7.0.2): dependencies: '@sinclair/typebox': 0.34.49 cookie: 1.1.1 exact-mirror: 1.0.0 fast-decode-uri-component: 1.0.1 - file-type: 22.0.1 + file-type: 22.0.1(supports-color@7.2.0) memoirist: 0.4.0 openapi-types: 12.1.3 optionalDependencies: - typescript: 6.0.3 + typescript: 7.0.2 emoji-regex@8.0.0: {} @@ -10447,6 +11787,8 @@ snapshots: entities@8.0.0: {} + env-paths@2.2.1: {} + env-runner@0.1.9: dependencies: crossws: 0.4.5(srvx@0.11.16) @@ -10456,6 +11798,10 @@ snapshots: environment@1.1.0: {} + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -10489,34 +11835,34 @@ snapshots: esast-util-from-estree: 2.0.0 vfile-message: 4.0.3 - esbuild@0.28.0: + esbuild@0.28.2: optionalDependencies: - '@esbuild/aix-ppc64': 0.28.0 - '@esbuild/android-arm': 0.28.0 - '@esbuild/android-arm64': 0.28.0 - '@esbuild/android-x64': 0.28.0 - '@esbuild/darwin-arm64': 0.28.0 - '@esbuild/darwin-x64': 0.28.0 - '@esbuild/freebsd-arm64': 0.28.0 - '@esbuild/freebsd-x64': 0.28.0 - '@esbuild/linux-arm': 0.28.0 - '@esbuild/linux-arm64': 0.28.0 - '@esbuild/linux-ia32': 0.28.0 - '@esbuild/linux-loong64': 0.28.0 - '@esbuild/linux-mips64el': 0.28.0 - '@esbuild/linux-ppc64': 0.28.0 - '@esbuild/linux-riscv64': 0.28.0 - '@esbuild/linux-s390x': 0.28.0 - '@esbuild/linux-x64': 0.28.0 - '@esbuild/netbsd-arm64': 0.28.0 - '@esbuild/netbsd-x64': 0.28.0 - '@esbuild/openbsd-arm64': 0.28.0 - '@esbuild/openbsd-x64': 0.28.0 - '@esbuild/openharmony-arm64': 0.28.0 - '@esbuild/sunos-x64': 0.28.0 - '@esbuild/win32-arm64': 0.28.0 - '@esbuild/win32-ia32': 0.28.0 - '@esbuild/win32-x64': 0.28.0 + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 escalade@3.2.0: {} @@ -10590,20 +11936,20 @@ snapshots: expect-type@1.3.0: {} - express@5.2.1: + express@5.2.1(supports-color@7.2.0): dependencies: accepts: 2.0.0 - body-parser: 2.2.2 + body-parser: 2.2.2(supports-color@7.2.0) content-disposition: 1.1.0 content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - finalhandler: 2.1.1 + finalhandler: 2.1.1(supports-color@7.2.0) fresh: 2.0.0 http-errors: 2.0.1 merge-descriptors: 2.0.0 @@ -10614,9 +11960,9 @@ snapshots: proxy-addr: 2.0.7 qs: 6.15.2 range-parser: 1.2.1 - router: 2.2.0 - send: 1.2.1 - serve-static: 2.2.1 + router: 2.2.0(supports-color@7.2.0) + send: 1.2.1(supports-color@7.2.0) + serve-static: 2.2.1(supports-color@7.2.0) statuses: 2.0.2 type-is: 2.1.0 vary: 1.1.2 @@ -10694,10 +12040,6 @@ snapshots: dependencies: reusify: 1.1.0 - fdir@6.5.0(picomatch@4.0.4): - optionalDependencies: - picomatch: 4.0.4 - fdir@6.5.0(picomatch@4.0.7): optionalDependencies: picomatch: 4.0.7 @@ -10708,9 +12050,18 @@ snapshots: dependencies: is-unicode-supported: 2.1.0 - file-type@22.0.1: + file-type@21.3.4(supports-color@7.2.0): + dependencies: + '@tokenizer/inflate': 0.4.1(supports-color@7.2.0) + strtok3: 10.3.5 + token-types: 6.1.2 + uint8array-extras: 1.5.0 + transitivePeerDependencies: + - supports-color + + file-type@22.0.1(supports-color@7.2.0): dependencies: - '@tokenizer/inflate': 0.4.1 + '@tokenizer/inflate': 0.4.1(supports-color@7.2.0) strtok3: 10.3.5 token-types: 6.1.2 uint8array-extras: 1.5.0 @@ -10721,9 +12072,9 @@ snapshots: dependencies: to-regex-range: 5.0.1 - finalhandler@2.1.1: + finalhandler@2.1.1(supports-color@7.2.0): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -10744,6 +12095,8 @@ snapshots: pkg-types: 1.3.1 yaml: 2.9.0 + flexsearch@0.8.212: {} + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -10765,10 +12118,10 @@ snapshots: forwarded@0.2.0: {} - framer-motion@12.40.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + framer-motion@13.1.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: - motion-dom: 12.40.0 - motion-utils: 12.39.0 + motion-dom: 13.1.1 + motion-utils: 13.0.0 tslib: 2.8.1 optionalDependencies: react: 19.2.6 @@ -10776,129 +12129,103 @@ snapshots: fresh@2.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true - fumadocs-core@16.9.3(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.15(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@1.17.0(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3): + fumadocs-core@16.15.4(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@tanstack/react-router@1.170.15(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.15)(flexsearch@0.8.212)(lucide-react@1.34.0(react@19.2.6))(next@16.3.3(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.62.1)(@types/node@25.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(supports-color@7.2.0)(zod@4.4.3): dependencies: - '@orama/orama': 3.1.18 + '@fumari/image-size': 0.1.0 estree-util-value-to-estree: 3.5.0 github-slugger: 2.0.0 - hast-util-to-estree: 3.1.3 - hast-util-to-jsx-runtime: 2.3.6 - js-yaml: 4.1.1 - mdast-util-mdx: 3.0.0 + hast-util-to-estree: 3.1.3(supports-color@7.2.0) + hast-util-to-jsx-runtime: 2.3.6(supports-color@7.2.0) + mdast-util-mdx: 3.0.0(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 - remark: 15.0.1 - remark-gfm: 4.0.1 + npm-to-yarn: 3.2.0 + remark: 15.0.1(supports-color@7.2.0) + remark-gfm: 4.0.1(supports-color@7.2.0) remark-rehype: 11.1.2 scroll-into-view-if-needed: 3.1.0 - shiki: 4.1.0 + shiki: 4.4.3 tinyglobby: 0.2.17 unified: 11.0.5 unist-util-visit: 5.1.0 vfile: 6.0.3 + yaml: 2.9.0 + zbsearch: 4.0.0 optionalDependencies: - '@mdx-js/mdx': 3.1.1 + '@mdx-js/mdx': 3.1.1(supports-color@7.2.0) '@tanstack/react-router': 1.170.15(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 '@types/react': 19.2.15 - lucide-react: 1.17.0(react@19.2.6) - next: 16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + flexsearch: 0.8.212 + lucide-react: 1.34.0(react@19.2.6) + next: 16.3.3(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.62.1)(@types/node@25.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) zod: 4.4.3 transitivePeerDependencies: - supports-color - fumadocs-mdx@15.0.10(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.15)(fumadocs-core@16.9.3(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.15(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@1.17.0(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(rolldown@1.0.3)(vite@8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): + fumadocs-mdx@15.4.0(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.15)(fumadocs-core@16.15.4(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@tanstack/react-router@1.170.15(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.15)(flexsearch@0.8.212)(lucide-react@1.34.0(react@19.2.6))(next@16.3.3(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.62.1)(@types/node@25.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(supports-color@7.2.0)(zod@4.4.3))(next@16.3.3(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.62.1)(@types/node@25.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(rolldown@1.0.3)(supports-color@7.2.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: - '@mdx-js/mdx': 3.1.1 + '@mdx-js/mdx': 3.1.1(supports-color@7.2.0) '@standard-schema/spec': 1.1.0 chokidar: 5.0.0 - esbuild: 0.28.0 + esbuild: 0.28.2 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 16.9.3(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.15(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@1.17.0(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3) - js-yaml: 4.1.1 - mdast-util-mdx: 3.0.0 + fumadocs-core: 16.15.4(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@tanstack/react-router@1.170.15(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.15)(flexsearch@0.8.212)(lucide-react@1.34.0(react@19.2.6))(next@16.3.3(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.62.1)(@types/node@25.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(supports-color@7.2.0)(zod@4.4.3) + github-slugger: 2.0.0 + magic-string: 1.2.3 + mdast-util-mdx: 3.0.0(supports-color@7.2.0) picocolors: 1.1.1 - picomatch: 4.0.4 - tinyexec: 1.2.3 + picomatch: 4.0.7 + tinyexec: 1.3.0 tinyglobby: 0.2.17 unified: 11.0.5 unist-util-remove-position: 5.0.0 unist-util-visit: 5.1.0 vfile: 6.0.3 + yaml: 2.9.0 + yuku-analyzer: 0.9.3 zod: 4.4.3 optionalDependencies: '@types/mdast': 4.0.4 '@types/mdx': 2.0.13 '@types/react': 19.2.15 - next: 16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + next: 16.3.3(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.62.1)(@types/node@25.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: 19.2.6 rolldown: 1.0.3 - vite: 8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) transitivePeerDependencies: - supports-color - fumadocs-twoslash@3.2.0(1d030e86b7f7b9132e23a7c3ab6b57d0): + fumadocs-twoslash@3.3.0(dee5151bb43a354735c1902652f806d2): dependencies: - '@radix-ui/react-popover': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@shikijs/twoslash': 4.1.0(typescript@6.0.3) - fumadocs-core: 16.9.3(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.15(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@1.17.0(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3) - fumadocs-ui: 16.9.3(@tailwindcss/oxide@4.3.0)(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(fumadocs-core@16.9.3(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.15(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@1.17.0(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tailwindcss@4.3.0) - mdast-util-from-markdown: 2.0.3 - mdast-util-gfm: 3.1.0 - mdast-util-to-hast: 13.2.1 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - shiki: 4.1.0 - tailwind-merge: 3.6.0 - twoslash: 0.3.8(typescript@6.0.3) - optionalDependencies: - '@types/react': 19.2.15 - transitivePeerDependencies: - - '@types/react-dom' - - supports-color - - typescript - - fumadocs-ui@16.9.3(@tailwindcss/oxide@4.3.0)(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(fumadocs-core@16.9.3(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.15(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@1.17.0(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tailwindcss@4.3.0): - dependencies: - '@fumadocs/tailwind': 0.0.5(@tailwindcss/oxide@4.3.0)(tailwindcss@4.3.0) - '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-direction': 1.1.1(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-navigation-menu': 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-popover': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-scroll-area': 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-slot': 1.2.4(@types/react@19.2.15)(react@19.2.6) - '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - class-variance-authority: 0.7.1 - fumadocs-core: 16.9.3(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.15(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.15)(lucide-react@1.17.0(react@19.2.6))(next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3) - lucide-react: 1.17.0(react@19.2.6) - motion: 12.40.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - next-themes: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - react-remove-scroll: 2.7.2(@types/react@19.2.15)(react@19.2.6) - rehype-raw: 7.0.0 - scroll-into-view-if-needed: 3.1.0 - shiki: 4.1.0 - tailwind-merge: 3.6.0 - unist-util-visit: 5.1.0 + '@base-ui/react': 1.7.0(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@shikijs/twoslash': 4.4.3(supports-color@7.2.0)(typescript@7.0.2) + cnfast: 0.0.8 + fumadocs-core: 16.15.4(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@tanstack/react-router@1.170.15(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.15)(flexsearch@0.8.212)(lucide-react@1.34.0(react@19.2.6))(next@16.3.3(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.62.1)(@types/node@25.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(supports-color@7.2.0)(zod@4.4.3) + fumadocs-ui: '@fumadocs/base-ui@16.15.4(@types/mdx@2.0.13)(@types/react@19.2.15)(fumadocs-core@16.15.4(@mdx-js/mdx@3.1.1(supports-color@7.2.0))(@tanstack/react-router@1.170.15(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.15)(flexsearch@0.8.212)(lucide-react@1.34.0(react@19.2.6))(next@16.3.3(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.62.1)(@types/node@25.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(supports-color@7.2.0)(zod@4.4.3))(next@16.3.3(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.62.1)(@types/node@25.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tailwindcss@4.3.0)' + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) + mdast-util-gfm: 3.1.0(supports-color@7.2.0) + mdast-util-to-hast: 13.2.1 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + shiki: 4.4.3 + twoslash: 0.3.9(supports-color@7.2.0)(typescript@7.0.2) optionalDependencies: - '@types/mdx': 2.0.13 '@types/react': 19.2.15 - next: 16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) transitivePeerDependencies: - - '@emotion/is-prop-valid' - - '@tailwindcss/oxide' - - '@types/react-dom' - - tailwindcss + - '@date-fns/tz' + - date-fns + - supports-color + - typescript function-bind@1.1.2: {} @@ -10939,6 +12266,14 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + git-raw-commits@5.0.1(conventional-commits-parser@6.4.0): + dependencies: + '@conventional-changelog/git-client': 2.7.0(conventional-commits-parser@6.4.0) + meow: 13.2.0 + transitivePeerDependencies: + - conventional-commits-filter + - conventional-commits-parser + github-slugger@2.0.0: {} glob-parent@5.1.2: @@ -10954,6 +12289,10 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 + global-directory@5.0.0: + dependencies: + ini: 6.0.0 + globrex@0.1.2: {} goober@2.1.19(csstype@3.2.3): @@ -10964,6 +12303,18 @@ snapshots: graceful-fs@4.2.11: {} + h3@1.15.11: + dependencies: + cookie-es: 1.2.3 + crossws: 0.3.5 + defu: 6.1.7 + destr: 2.0.5 + iron-webcrypto: 1.2.1 + node-mock-http: 1.0.5 + radix3: 1.1.2 + ufo: 1.6.4 + uncrypto: 0.1.3 + h3@2.0.1-rc.20(crossws@0.4.5(srvx@0.11.16)): dependencies: rou3: 0.8.1 @@ -11035,7 +12386,7 @@ snapshots: web-namespaces: 2.0.1 zwitch: 2.0.4 - hast-util-to-estree@3.1.3: + hast-util-to-estree@3.1.3(supports-color@7.2.0): dependencies: '@types/estree': 1.0.9 '@types/estree-jsx': 1.0.5 @@ -11045,9 +12396,9 @@ snapshots: estree-util-attach-comments: 3.0.0 estree-util-is-identifier-name: 3.0.0 hast-util-whitespace: 3.0.0 - mdast-util-mdx-expression: 2.0.1 - mdast-util-mdx-jsx: 3.2.0 - mdast-util-mdxjs-esm: 2.0.1 + mdast-util-mdx-expression: 2.0.1(supports-color@7.2.0) + mdast-util-mdx-jsx: 3.2.0(supports-color@7.2.0) + mdast-util-mdxjs-esm: 2.0.1(supports-color@7.2.0) property-information: 7.1.0 space-separated-tokens: 2.0.2 style-to-js: 1.1.21 @@ -11058,7 +12409,7 @@ snapshots: hast-util-to-html@9.0.5: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/unist': 3.0.3 ccount: 2.0.1 comma-separated-tokens: 2.0.3 @@ -11070,18 +12421,18 @@ snapshots: stringify-entities: 4.0.4 zwitch: 2.0.4 - hast-util-to-jsx-runtime@2.3.6: + hast-util-to-jsx-runtime@2.3.6(supports-color@7.2.0): dependencies: '@types/estree': 1.0.9 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/unist': 3.0.3 comma-separated-tokens: 2.0.3 devlop: 1.1.0 estree-util-is-identifier-name: 3.0.0 hast-util-whitespace: 3.0.0 - mdast-util-mdx-expression: 2.0.1 - mdast-util-mdx-jsx: 3.2.0 - mdast-util-mdxjs-esm: 2.0.1 + mdast-util-mdx-expression: 2.0.1(supports-color@7.2.0) + mdast-util-mdx-jsx: 3.2.0(supports-color@7.2.0) + mdast-util-mdxjs-esm: 2.0.1(supports-color@7.2.0) property-information: 7.1.0 space-separated-tokens: 2.0.2 style-to-js: 1.1.21 @@ -11102,7 +12453,7 @@ snapshots: hast-util-whitespace@3.0.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hastscript@9.0.1: dependencies: @@ -11114,6 +12465,8 @@ snapshots: hono@4.12.23: {} + hono@4.13.5: {} + hookable@6.1.1: {} html-encoding-sniffer@6.0.0(@noble/hashes@1.8.0): @@ -11136,17 +12489,17 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 - http-proxy-agent@7.0.2: + http-proxy-agent@7.0.2(supports-color@7.2.0): dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color - https-proxy-agent@7.0.6: + https-proxy-agent@7.0.6(supports-color@7.2.0): dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -11166,6 +12519,11 @@ snapshots: ieee754@1.2.1: {} + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + import-meta-resolve@4.2.0: {} import-without-cache@0.4.0: {} @@ -11176,6 +12534,8 @@ snapshots: ini@1.3.8: {} + ini@6.0.0: {} + inline-style-parser@0.2.7: {} internmap@1.0.1: {} @@ -11186,6 +12546,8 @@ snapshots: ipaddr.js@2.4.0: {} + iron-webcrypto@1.2.1: {} + is-alphabetical@2.0.1: {} is-alphanumerical@2.0.1: @@ -11193,6 +12555,8 @@ snapshots: is-alphabetical: 2.0.1 is-decimal: 2.0.1 + is-arrayish@0.2.1: {} + is-decimal@2.0.1: {} is-extglob@2.1.1: {} @@ -11211,6 +12575,8 @@ snapshots: is-number@7.0.0: {} + is-obj@2.0.0: {} + is-plain-obj@4.1.0: {} is-potential-custom-element-name@1.0.1: {} @@ -11246,12 +12612,16 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 + iterare@1.2.1: {} + jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 optionalDependencies: '@pkgjs/parseargs': 0.11.0 + jiti@2.6.1: {} + jiti@2.7.0: {} js-beautify@1.15.4: @@ -11272,7 +12642,7 @@ snapshots: dependencies: argparse: 2.0.1 - jsdom@28.1.0(@noble/hashes@1.8.0): + jsdom@28.1.0(@noble/hashes@1.8.0)(supports-color@7.2.0): dependencies: '@acemir/cssom': 0.9.31 '@asamuzakjp/dom-selector': 6.8.1 @@ -11282,8 +12652,8 @@ snapshots: data-urls: 7.0.0(@noble/hashes@1.8.0) decimal.js: 10.6.0 html-encoding-sniffer: 6.0.0(@noble/hashes@1.8.0) - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + http-proxy-agent: 7.0.2(supports-color@7.2.0) + https-proxy-agent: 7.0.6(supports-color@7.2.0) is-potential-custom-element-name: 1.0.1 parse5: 8.0.1 saxes: 6.0.0 @@ -11301,6 +12671,8 @@ snapshots: jsesc@3.1.0: {} + json-parse-even-better-errors@2.3.1: {} + json-parse-even-better-errors@6.0.0: {} json-schema-ref-resolver@3.0.0: @@ -11385,6 +12757,10 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + lines-and-columns@1.2.4: {} + + load-esm@1.0.3: {} + locate-character@3.0.0: {} lodash-es@4.18.1: {} @@ -11412,7 +12788,7 @@ snapshots: dependencies: react: 19.2.6 - lucide-react@1.17.0(react@19.2.6): + lucide-react@1.34.0(react@19.2.6): dependencies: react: 19.2.6 @@ -11422,11 +12798,9 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - magicast@0.5.3: + magic-string@1.2.3: dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 - source-map-js: 1.2.1 + '@jridgewell/sourcemap-codec': 1.5.5 magicast@0.5.4: dependencies: @@ -11436,7 +12810,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.8.1 + semver: 7.8.5 markdown-extensions@2.0.0: {} @@ -11453,14 +12827,14 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 - mdast-util-from-markdown@2.0.3: + mdast-util-from-markdown@2.0.3(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 '@types/unist': 3.0.3 decode-named-character-reference: 1.3.0 devlop: 1.1.0 mdast-util-to-string: 4.0.0 - micromark: 4.0.2 + micromark: 4.0.2(supports-color@7.2.0) micromark-util-decode-numeric-character-reference: 2.0.2 micromark-util-decode-string: 2.0.1 micromark-util-normalize-identifier: 2.0.1 @@ -11478,75 +12852,75 @@ snapshots: mdast-util-find-and-replace: 3.0.2 micromark-util-character: 2.1.1 - mdast-util-gfm-footnote@2.1.0: + mdast-util-gfm-footnote@2.1.0(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 micromark-util-normalize-identifier: 2.0.1 transitivePeerDependencies: - supports-color - mdast-util-gfm-strikethrough@2.0.0: + mdast-util-gfm-strikethrough@2.0.0(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm-table@2.0.0: + mdast-util-gfm-table@2.0.0(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 markdown-table: 3.0.4 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm-task-list-item@2.0.0: + mdast-util-gfm-task-list-item@2.0.0(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm@3.1.0: + mdast-util-gfm@3.1.0(supports-color@7.2.0): dependencies: - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) mdast-util-gfm-autolink-literal: 2.0.1 - mdast-util-gfm-footnote: 2.1.0 - mdast-util-gfm-strikethrough: 2.0.0 - mdast-util-gfm-table: 2.0.0 - mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-gfm-footnote: 2.1.0(supports-color@7.2.0) + mdast-util-gfm-strikethrough: 2.0.0(supports-color@7.2.0) + mdast-util-gfm-table: 2.0.0(supports-color@7.2.0) + mdast-util-gfm-task-list-item: 2.0.0(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-mdx-expression@2.0.1: + mdast-util-mdx-expression@2.0.1(supports-color@7.2.0): dependencies: '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-mdx-jsx@3.2.0: + mdast-util-mdx-jsx@3.2.0(supports-color@7.2.0): dependencies: '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 '@types/unist': 3.0.3 ccount: 2.0.1 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 parse-entities: 4.0.2 stringify-entities: 4.0.4 @@ -11555,23 +12929,23 @@ snapshots: transitivePeerDependencies: - supports-color - mdast-util-mdx@3.0.0: + mdast-util-mdx@3.0.0(supports-color@7.2.0): dependencies: - mdast-util-from-markdown: 2.0.3 - mdast-util-mdx-expression: 2.0.1 - mdast-util-mdx-jsx: 3.2.0 - mdast-util-mdxjs-esm: 2.0.1 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) + mdast-util-mdx-expression: 2.0.1(supports-color@7.2.0) + mdast-util-mdx-jsx: 3.2.0(supports-color@7.2.0) + mdast-util-mdxjs-esm: 2.0.1(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-mdxjs-esm@2.0.1: + mdast-util-mdxjs-esm@2.0.1(supports-color@7.2.0): dependencies: '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color @@ -11611,12 +12985,16 @@ snapshots: mdn-data@2.27.1: {} + media-typer@0.3.0: {} + media-typer@1.1.0: {} memoirist@0.4.0: {} memorystream@0.3.1: {} + meow@13.2.0: {} + merge-anything@5.1.7: dependencies: is-what: 4.1.16 @@ -11893,10 +13271,10 @@ snapshots: micromark-util-types@2.0.2: {} - micromark@4.0.2: + micromark@4.0.2(supports-color@7.2.0): dependencies: '@types/debug': 4.1.13 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) decode-named-character-reference: 1.3.0 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 @@ -11942,6 +13320,8 @@ snapshots: dependencies: brace-expansion: 2.1.1 + minimist@1.2.8: {} + minipass@7.1.3: {} mlly@1.8.2: @@ -11951,15 +13331,15 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.4 - motion-dom@12.40.0: + motion-dom@13.1.1: dependencies: - motion-utils: 12.39.0 + motion-utils: 13.0.0 - motion-utils@12.39.0: {} + motion-utils@13.0.0: {} - motion@12.40.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + motion@13.1.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: - framer-motion: 12.40.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + framer-motion: 13.1.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) tslib: 2.8.1 optionalDependencies: react: 19.2.6 @@ -11973,8 +13353,17 @@ snapshots: muggle-string@0.4.1: {} + multer@2.2.0: + dependencies: + append-field: 1.0.0 + busboy: 1.6.0 + concat-stream: 2.0.0 + type-is: 1.6.18 + nanoid@3.3.12: {} + nanoid@3.3.18: {} + negotiator@1.0.0: {} next-themes@0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6): @@ -11982,55 +13371,55 @@ snapshots: react: 19.2.6 react-dom: 19.2.6(react@19.2.6) - next@16.2.6(@babel/core@7.29.7)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + next@15.5.24(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.62.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: - '@next/env': 16.2.6 + '@next/env': 15.5.24 '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.10.33 caniuse-lite: 1.0.30001793 postcss: 8.4.31 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) - styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.6) + styled-jsx: 5.1.6(@babel/core@7.29.7(supports-color@7.2.0))(react@19.2.6) optionalDependencies: - '@next/swc-darwin-arm64': 16.2.6 - '@next/swc-darwin-x64': 16.2.6 - '@next/swc-linux-arm64-gnu': 16.2.6 - '@next/swc-linux-arm64-musl': 16.2.6 - '@next/swc-linux-x64-gnu': 16.2.6 - '@next/swc-linux-x64-musl': 16.2.6 - '@next/swc-win32-arm64-msvc': 16.2.6 - '@next/swc-win32-x64-msvc': 16.2.6 + '@next/swc-darwin-arm64': 15.5.24 + '@next/swc-darwin-x64': 15.5.24 + '@next/swc-linux-arm64-gnu': 15.5.24 + '@next/swc-linux-arm64-musl': 15.5.24 + '@next/swc-linux-x64-gnu': 15.5.24 + '@next/swc-linux-x64-musl': 15.5.24 + '@next/swc-win32-arm64-msvc': 15.5.24 + '@next/swc-win32-x64-msvc': 15.5.24 + '@playwright/test': 1.62.1 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros - next@16.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@16.0.11(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.62.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: - '@next/env': 16.2.6 + '@next/env': 16.0.11 '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.10.33 caniuse-lite: 1.0.30001793 postcss: 8.4.31 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - styled-jsx: 5.1.6(react@19.2.4) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + styled-jsx: 5.1.6(@babel/core@7.29.7(supports-color@7.2.0))(react@19.2.6) optionalDependencies: - '@next/swc-darwin-arm64': 16.2.6 - '@next/swc-darwin-x64': 16.2.6 - '@next/swc-linux-arm64-gnu': 16.2.6 - '@next/swc-linux-arm64-musl': 16.2.6 - '@next/swc-linux-x64-gnu': 16.2.6 - '@next/swc-linux-x64-musl': 16.2.6 - '@next/swc-win32-arm64-msvc': 16.2.6 - '@next/swc-win32-x64-msvc': 16.2.6 + '@next/swc-darwin-arm64': 16.0.11 + '@next/swc-darwin-x64': 16.0.11 + '@next/swc-linux-arm64-gnu': 16.0.11 + '@next/swc-linux-arm64-musl': 16.0.11 + '@next/swc-linux-x64-gnu': 16.0.11 + '@next/swc-linux-x64-musl': 16.0.11 + '@next/swc-win32-arm64-msvc': 16.0.11 + '@next/swc-win32-x64-msvc': 16.0.11 + '@playwright/test': 1.62.1 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros - next@16.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + next@16.2.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.62.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: '@next/env': 16.2.6 '@swc/helpers': 0.5.15 @@ -12039,7 +13428,7 @@ snapshots: postcss: 8.4.31 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) - styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.6) + styled-jsx: 5.1.6(@babel/core@7.29.7(supports-color@7.2.0))(react@19.2.6) optionalDependencies: '@next/swc-darwin-arm64': 16.2.6 '@next/swc-darwin-x64': 16.2.6 @@ -12049,19 +13438,45 @@ snapshots: '@next/swc-linux-x64-musl': 16.2.6 '@next/swc-win32-arm64-msvc': 16.2.6 '@next/swc-win32-x64-msvc': 16.2.6 + '@playwright/test': 1.62.1 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros - optional: true + + next@16.3.3(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.62.1)(@types/node@25.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@next/env': 16.3.3 + '@swc/helpers': 0.5.23 + baseline-browser-mapping: 2.10.33 + caniuse-lite: 1.0.30001793 + postcss: 8.5.23 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + styled-jsx: 5.1.6(@babel/core@7.29.7(supports-color@7.2.0))(react@19.2.6) + optionalDependencies: + '@next/swc-darwin-arm64': 16.3.3 + '@next/swc-darwin-x64': 16.3.3 + '@next/swc-linux-arm64-gnu': 16.3.3 + '@next/swc-linux-arm64-musl': 16.3.3 + '@next/swc-linux-x64-gnu': 16.3.3 + '@next/swc-linux-x64-musl': 16.3.3 + '@next/swc-win32-arm64-msvc': 16.3.3 + '@next/swc-win32-x64-msvc': 16.3.3 + '@playwright/test': 1.62.1 + sharp: 0.35.4(@types/node@25.9.1) + transitivePeerDependencies: + - '@babel/core' + - '@types/node' + - babel-plugin-macros nf3@0.3.17: {} - nitro@3.0.260522-beta(chokidar@5.0.0)(drizzle-orm@1.0.0-rc.3(@sinclair/typebox@0.34.49)(@types/pg@8.20.0)(zod@4.4.3))(jiti@2.7.0)(lru-cache@11.5.1)(vite@8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): + nitro@3.0.260522-beta(chokidar@5.0.0)(drizzle-orm@1.0.0-rc.3(@sinclair/typebox@0.34.49)(@types/pg@8.20.0)(arktype@2.2.3)(valibot@1.4.2(typescript@7.0.2))(zod@4.4.3))(jiti@2.7.0)(lru-cache@11.5.1)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: consola: 3.4.2 crossws: 0.4.5(srvx@0.11.16) - db0: 0.3.4(drizzle-orm@1.0.0-rc.3(@sinclair/typebox@0.34.49)(@types/pg@8.20.0)(zod@4.4.3)) + db0: 0.3.4(drizzle-orm@1.0.0-rc.3(@sinclair/typebox@0.34.49)(@types/pg@8.20.0)(arktype@2.2.3)(valibot@1.4.2(typescript@7.0.2))(zod@4.4.3)) env-runner: 0.1.9 h3: 2.0.1-rc.22(crossws@0.4.5(srvx@0.11.16)) hookable: 6.1.1 @@ -12072,10 +13487,10 @@ snapshots: rolldown: 1.0.3 srvx: 0.11.16 unenv: 2.0.0-rc.24 - unstorage: 2.0.0-alpha.7(chokidar@5.0.0)(db0@0.3.4(drizzle-orm@1.0.0-rc.3(@sinclair/typebox@0.34.49)(@types/pg@8.20.0)(zod@4.4.3)))(lru-cache@11.5.1)(ofetch@2.0.0-alpha.3) + unstorage: 2.0.0-alpha.7(chokidar@5.0.0)(db0@0.3.4(drizzle-orm@1.0.0-rc.3(@sinclair/typebox@0.34.49)(@types/pg@8.20.0)(arktype@2.2.3)(valibot@1.4.2(typescript@7.0.2))(zod@4.4.3)))(lru-cache@11.5.1)(ofetch@2.0.0-alpha.3) optionalDependencies: jiti: 2.7.0 - vite: 8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -12109,6 +13524,8 @@ snapshots: node-fetch-native@1.6.7: {} + node-mock-http@1.0.5: {} + node-releases@2.0.46: {} nopt@7.2.1: @@ -12133,6 +13550,8 @@ snapshots: path-key: 4.0.0 unicorn-magic: 0.3.0 + npm-to-yarn@3.2.0: {} + nypm@0.6.9: dependencies: citty: 0.2.2 @@ -12211,6 +13630,30 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' + oxc-parser@0.147.0: + dependencies: + '@oxc-project/types': 0.147.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.147.0 + '@oxc-parser/binding-android-arm64': 0.147.0 + '@oxc-parser/binding-darwin-arm64': 0.147.0 + '@oxc-parser/binding-darwin-x64': 0.147.0 + '@oxc-parser/binding-freebsd-x64': 0.147.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.147.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.147.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.147.0 + '@oxc-parser/binding-linux-arm64-musl': 0.147.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.147.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.147.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.147.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.147.0 + '@oxc-parser/binding-linux-x64-gnu': 0.147.0 + '@oxc-parser/binding-linux-x64-musl': 0.147.0 + '@oxc-parser/binding-openharmony-arm64': 0.147.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.147.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.147.0 + '@oxc-parser/binding-win32-x64-msvc': 0.147.0 + oxfmt@0.64.0(svelte@5.56.0): dependencies: tinypool: 2.1.0 @@ -12272,6 +13715,10 @@ snapshots: package-manager-detector@1.6.0: {} + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 @@ -12282,6 +13729,13 @@ snapshots: is-decimal: 2.0.1 is-hexadecimal: 2.0.1 + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.7 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + parse-ms@4.0.0: {} parse5@7.3.0: @@ -12359,6 +13813,14 @@ snapshots: mlly: 1.8.2 pathe: 2.0.3 + playwright-core@1.62.1: {} + + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + pnpm-workspace-yaml@1.6.1: dependencies: yaml: 2.9.0 @@ -12387,6 +13849,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postcss@8.5.23: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + postgres-array@2.0.0: {} postgres-bytea@1.0.1: {} @@ -12438,6 +13906,8 @@ snapshots: radash@12.1.1: {} + radix3@1.1.2: {} + range-parser@1.2.1: {} raw-body@3.0.2: @@ -12447,11 +13917,6 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 - react-dom@19.2.4(react@19.2.4): - dependencies: - react: 19.2.4 - scheduler: 0.27.0 - react-dom@19.2.6(react@19.2.6): dependencies: react: 19.2.6 @@ -12486,8 +13951,6 @@ snapshots: optionalDependencies: '@types/react': 19.2.15 - react@19.2.4: {} - react@19.2.6: {} read-package-json-fast@6.0.0: @@ -12495,6 +13958,12 @@ snapshots: json-parse-even-better-errors: 6.0.0 npm-normalize-package-bin: 6.0.0 + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + readdirp@4.1.2: {} readdirp@5.0.0: {} @@ -12537,6 +14006,8 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + reflect-metadata@0.2.2: {} + regex-recursion@6.0.2: dependencies: regex-utilities: 2.3.0 @@ -12553,36 +14024,36 @@ snapshots: hast-util-raw: 9.1.0 vfile: 6.0.3 - rehype-recma@1.0.0: + rehype-recma@1.0.0(supports-color@7.2.0): dependencies: '@types/estree': 1.0.9 - '@types/hast': 3.0.4 - hast-util-to-estree: 3.1.3 + '@types/hast': 3.0.5 + hast-util-to-estree: 3.1.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color - remark-gfm@4.0.1: + remark-gfm@4.0.1(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 - mdast-util-gfm: 3.1.0 + mdast-util-gfm: 3.1.0(supports-color@7.2.0) micromark-extension-gfm: 3.0.0 - remark-parse: 11.0.0 + remark-parse: 11.0.0(supports-color@7.2.0) remark-stringify: 11.0.0 unified: 11.0.5 transitivePeerDependencies: - supports-color - remark-mdx@3.1.1: + remark-mdx@3.1.1(supports-color@7.2.0): dependencies: - mdast-util-mdx: 3.0.0 + mdast-util-mdx: 3.0.0(supports-color@7.2.0) micromark-extension-mdxjs: 3.0.0 transitivePeerDependencies: - supports-color - remark-parse@11.0.0: + remark-parse@11.0.0(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) micromark-util-types: 2.0.2 unified: 11.0.5 transitivePeerDependencies: @@ -12590,7 +14061,7 @@ snapshots: remark-rehype@11.1.2: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 mdast-util-to-hast: 13.2.1 unified: 11.0.5 @@ -12602,10 +14073,10 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 - remark@15.0.1: + remark@15.0.1(supports-color@7.2.0): dependencies: '@types/mdast': 4.0.4 - remark-parse: 11.0.0 + remark-parse: 11.0.0(supports-color@7.2.0) remark-stringify: 11.0.0 unified: 11.0.5 transitivePeerDependencies: @@ -12615,6 +14086,12 @@ snapshots: require-from-string@2.0.2: {} + reselect@5.3.0: {} + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + resolve-pkg-maps@1.0.0: {} resolve.exports@2.0.3: {} @@ -12632,7 +14109,7 @@ snapshots: robust-predicates@3.0.3: {} - rolldown-plugin-dts@0.25.2(rolldown@1.0.3)(typescript@6.0.3): + rolldown-plugin-dts@0.25.2(rolldown@1.0.3)(typescript@7.0.2): dependencies: '@babel/generator': 8.0.0-rc.6 '@babel/helper-validator-identifier': 8.0.0-rc.6 @@ -12644,7 +14121,7 @@ snapshots: obug: 2.1.1 rolldown: 1.0.3 optionalDependencies: - typescript: 6.0.3 + typescript: 7.0.2 transitivePeerDependencies: - oxc-resolver @@ -12678,9 +14155,9 @@ snapshots: points-on-curve: 0.2.0 points-on-path: 0.2.1 - router@2.2.0: + router@2.2.0(supports-color@7.2.0): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -12694,10 +14171,16 @@ snapshots: rw@1.3.3: {} + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + sade@1.8.1: dependencies: mri: 1.2.0 + safe-buffer@5.2.1: {} + safe-regex2@5.1.1: dependencies: ret: 0.5.0 @@ -12724,9 +14207,11 @@ snapshots: semver@7.8.1: {} - send@1.2.1: + semver@7.8.5: {} + + send@1.2.1(supports-color@7.2.0): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -12752,12 +14237,12 @@ snapshots: seroval@1.6.4: {} - serve-static@2.2.1: + serve-static@2.2.1(supports-color@7.2.0): dependencies: encodeurl: 2.0.0 escape-html: 1.0.3 parseurl: 1.3.3 - send: 1.2.1 + send: 1.2.1(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -12799,6 +14284,40 @@ snapshots: '@img/sharp-win32-x64': 0.34.5 optional: true + sharp@0.35.4(@types/node@25.9.1): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.4 + '@img/sharp-darwin-x64': 0.35.4 + '@img/sharp-freebsd-wasm32': 0.35.4 + '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-linux-arm': 0.35.4 + '@img/sharp-linux-arm64': 0.35.4 + '@img/sharp-linux-ppc64': 0.35.4 + '@img/sharp-linux-riscv64': 0.35.4 + '@img/sharp-linux-s390x': 0.35.4 + '@img/sharp-linux-x64': 0.35.4 + '@img/sharp-linuxmusl-arm64': 0.35.4 + '@img/sharp-linuxmusl-x64': 0.35.4 + '@img/sharp-webcontainers-wasm32': 0.35.4 + '@img/sharp-win32-arm64': 0.35.4 + '@img/sharp-win32-ia32': 0.35.4 + '@img/sharp-win32-x64': 0.35.4 + '@types/node': 25.9.1 + optional: true + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -12807,16 +14326,16 @@ snapshots: shell-quote@1.8.4: {} - shiki@4.1.0: + shiki@4.4.3: dependencies: - '@shikijs/core': 4.1.0 - '@shikijs/engine-javascript': 4.1.0 - '@shikijs/engine-oniguruma': 4.1.0 - '@shikijs/langs': 4.1.0 - '@shikijs/themes': 4.1.0 - '@shikijs/types': 4.1.0 + '@shikijs/core': 4.4.3 + '@shikijs/engine-javascript': 4.4.3 + '@shikijs/engine-oniguruma': 4.4.3 + '@shikijs/langs': 4.4.3 + '@shikijs/themes': 4.4.3 + '@shikijs/types': 4.4.3 '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 side-channel-list@1.0.1: dependencies: @@ -12869,10 +14388,10 @@ snapshots: seroval: 1.5.4 seroval-plugins: 1.5.4(seroval@1.5.4) - solid-refresh@0.6.3(solid-js@1.9.13): + solid-refresh@0.6.3(solid-js@1.9.13)(supports-color@7.2.0): dependencies: '@babel/generator': 7.29.7 - '@babel/helper-module-imports': 7.29.7 + '@babel/helper-module-imports': 7.29.7(supports-color@7.2.0) '@babel/types': 7.29.7 solid-js: 1.9.13 transitivePeerDependencies: @@ -12898,6 +14417,8 @@ snapshots: std-env@4.1.0: {} + streamsearch@1.1.0: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -12915,6 +14436,10 @@ snapshots: get-east-asian-width: 1.6.0 strip-ansi: 7.2.0 + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 @@ -12946,25 +14471,20 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.6): + styled-jsx@5.1.6(@babel/core@7.29.7(supports-color@7.2.0))(react@19.2.6): dependencies: client-only: 0.0.1 react: 19.2.6 optionalDependencies: - '@babel/core': 7.29.7 - - styled-jsx@5.1.6(react@19.2.4): - dependencies: - client-only: 0.0.1 - react: 19.2.4 + '@babel/core': 7.29.7(supports-color@7.2.0) stylis@4.4.0: {} - superagent@10.3.0: + superagent@10.3.0(supports-color@7.2.0): dependencies: component-emitter: 1.3.1 cookiejar: 2.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) fast-safe-stringify: 2.1.1 form-data: 4.0.5 formidable: 3.5.4 @@ -12974,11 +14494,11 @@ snapshots: transitivePeerDependencies: - supports-color - supertest@7.2.2: + supertest@7.2.2(supports-color@7.2.0): dependencies: cookie-signature: 1.2.2 methods: 1.1.2 - superagent: 10.3.0 + superagent: 10.3.0(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -12986,7 +14506,7 @@ snapshots: dependencies: has-flag: 4.0.0 - svelte-check@4.5.0(picomatch@4.0.7)(svelte@5.56.0)(typescript@6.0.3): + svelte-check@4.5.0(picomatch@4.0.7)(svelte@5.56.0)(typescript@7.0.2): dependencies: '@jridgewell/trace-mapping': 0.3.31 chokidar: 4.0.3 @@ -12994,16 +14514,16 @@ snapshots: picocolors: 1.1.1 sade: 1.8.1 svelte: 5.56.0 - typescript: 6.0.3 + typescript: 7.0.2 transitivePeerDependencies: - picomatch - svelte2tsx@0.7.55(svelte@5.56.0)(typescript@6.0.3): + svelte2tsx@0.7.55(svelte@5.56.0)(typescript@7.0.2): dependencies: dedent-js: 1.0.1 scule: 1.3.0 svelte: 5.56.0 - typescript: 6.0.3 + typescript: 7.0.2 svelte@5.56.0: dependencies: @@ -13063,8 +14583,8 @@ snapshots: tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 tinypool@2.1.0: {} @@ -13108,11 +14628,11 @@ snapshots: ts-dedent@2.2.0: {} - tsconfck@3.1.6(typescript@6.0.3): + tsconfck@3.1.6(typescript@7.0.2): optionalDependencies: - typescript: 6.0.3 + typescript: 7.0.2 - tsdown@0.22.1(tsx@4.22.4)(typescript@6.0.3): + tsdown@0.22.1(tsx@4.22.4)(typescript@7.0.2): dependencies: ansis: 4.3.0 cac: 7.0.0 @@ -13123,7 +14643,7 @@ snapshots: obug: 2.1.1 picomatch: 4.0.4 rolldown: 1.0.3 - rolldown-plugin-dts: 0.25.2(rolldown@1.0.3)(typescript@6.0.3) + rolldown-plugin-dts: 0.25.2(rolldown@1.0.3)(typescript@7.0.2) semver: 7.8.1 tinyexec: 1.2.3 tinyglobby: 0.2.17 @@ -13131,7 +14651,7 @@ snapshots: unconfig-core: 7.5.0 optionalDependencies: tsx: 4.22.4 - typescript: 6.0.3 + typescript: 7.0.2 transitivePeerDependencies: - '@ts-macro/tsc' - '@typescript/native-preview' @@ -13142,26 +14662,26 @@ snapshots: tsx@4.22.4: dependencies: - esbuild: 0.28.0 + esbuild: 0.28.2 optionalDependencies: fsevents: 2.3.3 - turbo@2.9.16: + turbo@2.10.12: optionalDependencies: - '@turbo/darwin-64': 2.9.16 - '@turbo/darwin-arm64': 2.9.16 - '@turbo/linux-64': 2.9.16 - '@turbo/linux-arm64': 2.9.16 - '@turbo/windows-64': 2.9.16 - '@turbo/windows-arm64': 2.9.16 + '@turbo/darwin-64': 2.10.12 + '@turbo/darwin-arm64': 2.10.12 + '@turbo/linux-64': 2.10.12 + '@turbo/linux-arm64': 2.10.12 + '@turbo/windows-64': 2.10.12 + '@turbo/windows-arm64': 2.10.12 - twoslash-protocol@0.3.8: {} + twoslash-protocol@0.3.9: {} - twoslash@0.3.8(typescript@6.0.3): + twoslash@0.3.9(supports-color@7.2.0)(typescript@7.0.2): dependencies: - '@typescript/vfs': 1.6.4(typescript@6.0.3) - twoslash-protocol: 0.3.8 - typescript: 6.0.3 + '@typescript/vfs': 1.6.4(supports-color@7.2.0)(typescript@7.0.2) + twoslash-protocol: 0.3.9 + typescript: 7.0.2 transitivePeerDependencies: - supports-color @@ -13169,16 +14689,52 @@ snapshots: dependencies: tagged-tag: 1.0.0 + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + type-is@2.1.0: dependencies: content-type: 2.0.0 media-typer: 1.1.0 mime-types: 3.0.2 + typedarray@0.0.6: {} + + typescript@5.9.3: {} + typescript@6.0.3: {} + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + ufo@1.6.4: {} + uid@2.0.2: + dependencies: + '@lukeed/csprng': 1.1.0 + uint8array-extras@1.5.0: {} ultracite@7.10.6(oxfmt@0.64.0(svelte@5.56.0))(oxlint@1.80.0(oxlint-tsgolint@7.0.2001)): @@ -13216,9 +14772,7 @@ snapshots: quansync: 1.0.0 unconfig-core: 7.5.0 - undici-types@6.21.0: {} - - undici-types@7.16.0: {} + uncrypto@0.1.3: {} undici-types@7.24.6: {} @@ -13277,13 +14831,13 @@ snapshots: unplugin@3.0.0: dependencies: '@jridgewell/remapping': 2.3.5 - picomatch: 4.0.4 + picomatch: 4.0.7 webpack-virtual-modules: 0.6.2 - unstorage@2.0.0-alpha.7(chokidar@5.0.0)(db0@0.3.4(drizzle-orm@1.0.0-rc.3(@sinclair/typebox@0.34.49)(@types/pg@8.20.0)(zod@4.4.3)))(lru-cache@11.5.1)(ofetch@2.0.0-alpha.3): + unstorage@2.0.0-alpha.7(chokidar@5.0.0)(db0@0.3.4(drizzle-orm@1.0.0-rc.3(@sinclair/typebox@0.34.49)(@types/pg@8.20.0)(arktype@2.2.3)(valibot@1.4.2(typescript@7.0.2))(zod@4.4.3)))(lru-cache@11.5.1)(ofetch@2.0.0-alpha.3): optionalDependencies: chokidar: 5.0.0 - db0: 0.3.4(drizzle-orm@1.0.0-rc.3(@sinclair/typebox@0.34.49)(@types/pg@8.20.0)(zod@4.4.3)) + db0: 0.3.4(drizzle-orm@1.0.0-rc.3(@sinclair/typebox@0.34.49)(@types/pg@8.20.0)(arktype@2.2.3)(valibot@1.4.2(typescript@7.0.2))(zod@4.4.3)) lru-cache: 11.5.1 ofetch: 2.0.0-alpha.3 @@ -13316,6 +14870,10 @@ snapshots: uuid@14.0.0: {} + valibot@1.4.2(typescript@7.0.2): + optionalDependencies: + typescript: 7.0.2 + vary@1.1.2: {} vfile-location@5.0.3: @@ -13333,124 +14891,54 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): - dependencies: - '@babel/core': 7.29.7 - '@types/babel__core': 7.20.5 - babel-preset-solid: 1.9.12(@babel/core@7.29.7)(solid-js@1.9.13) - merge-anything: 5.1.7 - solid-js: 1.9.13 - solid-refresh: 0.6.3(solid-js@1.9.13) - vite: 8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) - vitefu: 1.1.3(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) - optionalDependencies: - '@testing-library/jest-dom': 6.9.1 - transitivePeerDependencies: - - supports-color - optional: true - - vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(vite@8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): - dependencies: - '@babel/core': 7.29.7 - '@types/babel__core': 7.20.5 - babel-preset-solid: 1.9.12(@babel/core@7.29.7)(solid-js@1.9.13) - merge-anything: 5.1.7 - solid-js: 1.9.13 - solid-refresh: 0.6.3(solid-js@1.9.13) - vite: 8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) - vitefu: 1.1.3(vite@8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) - optionalDependencies: - '@testing-library/jest-dom': 6.9.1 - transitivePeerDependencies: - - supports-color - optional: true - - vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): + vite-plugin-solid@2.11.12(@testing-library/jest-dom@6.9.1)(solid-js@1.9.13)(supports-color@7.2.0)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@7.2.0) '@types/babel__core': 7.20.5 - babel-preset-solid: 1.9.12(@babel/core@7.29.7)(solid-js@1.9.13) + babel-preset-solid: 1.9.12(@babel/core@7.29.7(supports-color@7.2.0))(solid-js@1.9.13) merge-anything: 5.1.7 solid-js: 1.9.13 - solid-refresh: 0.6.3(solid-js@1.9.13) - vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) - vitefu: 1.1.3(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + solid-refresh: 0.6.3(solid-js@1.9.13)(supports-color@7.2.0) + vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + vitefu: 1.1.3(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) optionalDependencies: '@testing-library/jest-dom': 6.9.1 transitivePeerDependencies: - supports-color - vite-tsconfig-paths@6.1.1(typescript@6.0.3)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): + vite-tsconfig-paths@6.1.1(supports-color@7.2.0)(typescript@7.0.2)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) globrex: 0.1.2 - tsconfck: 3.1.6(typescript@6.0.3) - vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + tsconfck: 3.1.6(typescript@7.0.2) + vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) transitivePeerDependencies: - supports-color - typescript - vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0): - dependencies: - lightningcss: 1.32.0 - picomatch: 4.0.4 - postcss: 8.5.15 - rolldown: 1.0.3 - tinyglobby: 0.2.17 - optionalDependencies: - '@types/node': 22.19.20 - esbuild: 0.28.0 - fsevents: 2.3.3 - jiti: 2.7.0 - tsx: 4.22.4 - yaml: 2.9.0 - - vite@8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0): - dependencies: - lightningcss: 1.32.0 - picomatch: 4.0.4 - postcss: 8.5.15 - rolldown: 1.0.3 - tinyglobby: 0.2.17 - optionalDependencies: - '@types/node': 24.10.0 - esbuild: 0.28.0 - fsevents: 2.3.3 - jiti: 2.7.0 - tsx: 4.22.4 - yaml: 2.9.0 - - vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0): + vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 - picomatch: 4.0.4 - postcss: 8.5.15 + picomatch: 4.0.7 + postcss: 8.5.23 rolldown: 1.0.3 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 25.9.1 - esbuild: 0.28.0 + esbuild: 0.28.2 fsevents: 2.3.3 jiti: 2.7.0 tsx: 4.22.4 yaml: 2.9.0 - vitefu@1.1.3(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): - optionalDependencies: - vite: 8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) - - vitefu@1.1.3(vite@8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): - optionalDependencies: - vite: 8.0.16(@types/node@24.10.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) - - vitefu@1.1.3(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): + vitefu@1.1.3(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): optionalDependencies: - vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) - vitest@4.1.8(@types/node@22.19.20)(@vitest/coverage-v8@4.1.8)(happy-dom@20.9.0)(jsdom@28.1.0(@noble/hashes@1.8.0))(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): + vitest@4.1.8(@types/node@25.9.1)(@vitest/coverage-v8@4.1.8)(happy-dom@20.9.0)(jsdom@28.1.0(@noble/hashes@1.8.0)(supports-color@7.2.0))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.8 - '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.8 '@vitest/runner': 4.1.8 '@vitest/snapshot': 4.1.8 @@ -13461,49 +14949,19 @@ snapshots: magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.4 - std-env: 4.1.0 - tinybench: 2.9.0 - tinyexec: 1.2.3 - tinyglobby: 0.2.17 - tinyrainbow: 3.1.0 - vite: 8.0.16(@types/node@22.19.20)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 22.19.20 - '@vitest/coverage-v8': 4.1.8(vitest@4.1.8) - happy-dom: 20.9.0 - jsdom: 28.1.0(@noble/hashes@1.8.0) - transitivePeerDependencies: - - msw - - vitest@4.1.8(@types/node@25.9.1)(@vitest/coverage-v8@4.1.8)(happy-dom@20.9.0)(jsdom@28.1.0(@noble/hashes@1.8.0))(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): - dependencies: - '@vitest/expect': 4.1.8 - '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) - '@vitest/pretty-format': 4.1.8 - '@vitest/runner': 4.1.8 - '@vitest/snapshot': 4.1.8 - '@vitest/spy': 4.1.8 - '@vitest/utils': 4.1.8 - es-module-lexer: 2.1.0 - expect-type: 1.3.0 - magic-string: 0.30.21 - obug: 2.1.1 - pathe: 2.0.3 - picomatch: 4.0.4 + picomatch: 4.0.7 std-env: 4.1.0 tinybench: 2.9.0 - tinyexec: 1.2.3 + tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.0.16(@types/node@25.9.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.9.1 '@vitest/coverage-v8': 4.1.8(vitest@4.1.8) happy-dom: 20.9.0 - jsdom: 28.1.0(@noble/hashes@1.8.0) + jsdom: 28.1.0(@noble/hashes@1.8.0)(supports-color@7.2.0) transitivePeerDependencies: - msw @@ -13511,21 +14969,21 @@ snapshots: vue-component-type-helpers@3.3.3: {} - vue-tsc@3.3.3(typescript@6.0.3): + vue-tsc@3.3.3(typescript@7.0.2): dependencies: - '@volar/typescript': 2.4.28 + '@volar/typescript': 2.4.28(typescript@7.0.2) '@vue/language-core': 3.3.3 - typescript: 6.0.3 + typescript: 7.0.2 - vue@3.5.35(typescript@6.0.3): + vue@3.5.35(typescript@7.0.2): dependencies: '@vue/compiler-dom': 3.5.35 '@vue/compiler-sfc': 3.5.35 '@vue/runtime-dom': 3.5.35 - '@vue/server-renderer': 3.5.35(vue@3.5.35(typescript@6.0.3)) + '@vue/server-renderer': 3.5.35(vue@3.5.35(typescript@7.0.2)) '@vue/shared': 3.5.35 optionalDependencies: - typescript: 6.0.3 + typescript: 7.0.2 w3c-xmlserializer@5.0.0: dependencies: @@ -13620,6 +15078,30 @@ snapshots: yoctocolors@2.2.0: {} + yuku-analyzer@0.9.3: + dependencies: + '@yuku-toolchain/types': 0.9.3 + yuku-ast: 0.9.3 + optionalDependencies: + '@yuku-analyzer/binding-android-arm64': 0.9.3 + '@yuku-analyzer/binding-darwin-arm64': 0.9.3 + '@yuku-analyzer/binding-darwin-x64': 0.9.3 + '@yuku-analyzer/binding-freebsd-x64': 0.9.3 + '@yuku-analyzer/binding-linux-arm-gnu': 0.9.3 + '@yuku-analyzer/binding-linux-arm-musl': 0.9.3 + '@yuku-analyzer/binding-linux-arm64-gnu': 0.9.3 + '@yuku-analyzer/binding-linux-arm64-musl': 0.9.3 + '@yuku-analyzer/binding-linux-x64-gnu': 0.9.3 + '@yuku-analyzer/binding-linux-x64-musl': 0.9.3 + '@yuku-analyzer/binding-win32-arm64': 0.9.3 + '@yuku-analyzer/binding-win32-x64': 0.9.3 + + yuku-ast@0.9.3: + dependencies: + '@yuku-toolchain/types': 0.9.3 + + zbsearch@4.0.0: {} + zimmerframe@1.1.4: {} zod@4.4.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 01269547..6abab932 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,9 +1,46 @@ -shellEmulator: true packages: - permix + - permix/test/next - docs - examples/* allowBuilds: better-sqlite3: true esbuild: false sharp: false +catalog: + '@types/node': ^25.9.1 + '@types/react': ^19.2.15 + '@types/react-dom': ^19.2.3 + '@vitejs/plugin-react': ^6.0.2 + chokidar: ^5.0.0 + esbuild: ^0.28.2 + oxc-parser: ^0.147.0 + oxfmt: ^0.64.0 + oxlint: ^1.79.0 + oxlint-tsgolint: ^7.0.2001 + react: ^19.2.6 + react-dom: ^19.2.6 + tinyglobby: ^0.2.17 + tsx: ^4.22.4 + turbo: 2.10.12 + typescript: 7.0.2 + ultracite: ^7.10.6 + vite: ^8.0.16 + vitest: ^4.1.8 +catalogs: + typescript-classic: + typescript59: npm:typescript@5.9.3 + typescript6: npm:@typescript/typescript6@6.0.2 +catalogMode: strict +saveWorkspaceProtocol: rolling +strictPeerDependencies: true +peerDependencyRules: + allowedVersions: + typescript: '*' + drizzle-orm>effect: '*' +publicHoistPattern: + - '@typescript/*' +shellEmulator: true +minimumReleaseAgeExclude: + - '@fumadocs/base-ui@16.15.4' + - fumadocs-core@16.15.4 diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 00000000..5f11f934 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "packages": { + "permix": { + "release-type": "node", + "package-name": "permix", + "changelog-path": "../CHANGELOG.md", + "include-component-in-tag": false, + "include-v-in-tag": true, + "extra-files": [ + "skills/permix/SKILL.md", + "skills/permix-getting-started/SKILL.md" + ] + } + } +} diff --git a/scripts/generate-historical-changelog.mjs b/scripts/generate-historical-changelog.mjs new file mode 100644 index 00000000..0047d2a1 --- /dev/null +++ b/scripts/generate-historical-changelog.mjs @@ -0,0 +1,216 @@ +#!/usr/bin/env node +import { execFileSync } from 'node:child_process' +import { writeFileSync } from 'node:fs' +import path from 'node:path' + +const root = path.resolve(import.meta.dirname, '..') +const repoUrl = 'https://github.com/letstri/permix' + +function git(args, options = {}) { + return execFileSync('git', args, { + cwd: root, + encoding: 'utf-8', + ...options, + }).trim() +} + +function npmView(args) { + return execFileSync('npm', ['view', 'permix', ...args, '--json'], { + encoding: 'utf-8', + }).trim() +} + +function parseVersion(source) { + const match = source.match(/"version"\s*:\s*"([^"]+)"/) + return match?.[1] ?? null +} + +function versionAt(sha) { + for (const filePath of ['permix/package.json', 'package.json']) { + try { + const source = git(['show', `${sha}:${filePath}`], { + stdio: ['ignore', 'pipe', 'ignore'], + }) + const version = parseVersion(source) + if (version) { + return version + } + } catch { + // File missing at this commit. + } + } + return null +} + +function isBumpSubject(subject) { + return /^(chore:\s*)?(bump|update)\s+version(\s+to\s+v?\S+)?$/i.test( + subject.trim() + ) +} + +function classify(subject) { + const text = subject.trim() + const type = text.split(':')[0] ?? '' + if (type.includes('!') || /^breaking(\s+change)?(\(.+\))?!?:/i.test(text)) { + return 'breaking' + } + if (/^(feat|feature)(\(.+\))?:/i.test(text)) { + return 'added' + } + if (/^fix(\(.+\))?:/i.test(text)) { + return 'fixed' + } + if (/^docs(\(.+\))?:/i.test(text)) { + return 'docs' + } + return 'changed' +} + +function bucketLabel(key) { + switch (key) { + case 'breaking': { + return 'Breaking Changes' + } + case 'added': { + return 'Features' + } + case 'fixed': { + return 'Bug Fixes' + } + case 'docs': { + return 'Documentation' + } + case 'changed': { + return 'Miscellaneous' + } + default: { + throw new Error(`Unhandled changelog bucket: ${String(key)}`) + } + } +} + +function escapeMd(text) { + return text.replaceAll('<', '\\<') +} + +function dateOnly(iso) { + return iso.slice(0, 10) +} + +const versions = JSON.parse(npmView(['versions'])) +const timesRaw = JSON.parse(npmView(['time'])) +const times = Array.isArray(timesRaw) ? timesRaw[0] : timesRaw + +const bumpLog = git([ + 'log', + '--reverse', + '--pretty=%H', + '-G', + '"version":', + '--', + 'permix/package.json', + 'package.json', +]) + .split('\n') + .filter(Boolean) + +const versionToSha = new Map() +for (const sha of bumpLog) { + const version = versionAt(sha) + if (version) { + versionToSha.set(version, sha) + } +} + +const mapping = [] +const sections = [] +const order = ['breaking', 'added', 'fixed', 'docs', 'changed'] + +for (let i = 0; i < versions.length; i++) { + const version = versions[i] + const prev = i > 0 ? versions[i - 1] : null + const sha = versionToSha.get(version) ?? null + const prevSha = prev ? (versionToSha.get(prev) ?? null) : null + const published = + typeof times[version] === 'string' ? dateOnly(times[version]) : 'unknown' + + mapping.push({ version, sha, published }) + + let subjects = [] + if (sha) { + const range = prevSha ? `${prevSha}..${sha}` : sha + const log = git(['log', '--no-merges', '--pretty=%s', range]) + subjects = log ? log.split('\n').filter(Boolean) : [] + } + + const buckets = { + breaking: [], + added: [], + fixed: [], + docs: [], + changed: [], + } + + for (const subject of subjects) { + if (isBumpSubject(subject)) { + continue + } + buckets[classify(subject)].push(subject) + } + + const compare = prev + ? `${repoUrl}/compare/v${prev}...v${version}` + : `${repoUrl}/releases/tag/v${version}` + const heading = `## [${version}](${compare}) (${published})` + + const lines = [heading, ''] + let hasNotes = false + for (const key of order) { + const items = buckets[key] + if (items.length === 0) { + continue + } + hasNotes = true + lines.push(`### ${bucketLabel(key)}`, '') + for (const item of items) { + lines.push(`* ${escapeMd(item)}`) + } + lines.push('') + } + if (!hasNotes) { + lines.push('* Published to npm.', '') + } + sections.push(lines.join('\n')) +} + +sections.reverse() + +const changelog = `${[ + '# Changelog', + '', + 'All notable changes to `permix` are documented in this file.', + '', + 'The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),', + 'and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).', + 'Entries through 4.1.2 were reconstructed from npm publish dates and git history;', + 'later versions are maintained by Release Please.', + '', + ...sections, +] + .join('\n') + .replaceAll(/\n{3,}/g, '\n\n')}\n` + +writeFileSync(path.resolve(root, 'CHANGELOG.md'), changelog) +writeFileSync( + path.resolve(root, 'scripts/historical-releases.json'), + `${JSON.stringify(mapping, null, 2)}\n` +) + +console.log(`Wrote CHANGELOG.md with ${versions.length} versions`) +console.log( + `Mapped SHAs: ${mapping.filter((item) => item.sha).length}/${mapping.length}` +) +const missing = mapping.filter((item) => !item.sha).map((item) => item.version) +if (missing.length > 0) { + console.log(`Missing SHAs: ${missing.join(', ')}`) +} diff --git a/scripts/historical-releases.json b/scripts/historical-releases.json new file mode 100644 index 00000000..65564d79 --- /dev/null +++ b/scripts/historical-releases.json @@ -0,0 +1,362 @@ +[ + { + "version": "0.0.1-alpha.1", + "sha": null, + "published": "2025-01-13" + }, + { + "version": "0.0.1", + "sha": "343a0dfa21829f8c3b282c5439a164b89e752e68", + "published": "2025-01-14" + }, + { + "version": "0.1.0", + "sha": "0f802c4926fcb12e0624b6573891f568a02ad26f", + "published": "2025-01-15" + }, + { + "version": "0.1.1", + "sha": "f3d49b87305601197496ca8a5e3c806cd12b25ce", + "published": "2025-01-15" + }, + { + "version": "0.1.2", + "sha": "8f00fed0f0ae4618998dc899da5e6b1a92243d4c", + "published": "2025-01-15" + }, + { + "version": "0.2.0", + "sha": "40fc100371f449359fba619cb9b2358f6387e27c", + "published": "2025-01-15" + }, + { + "version": "0.2.1", + "sha": "5b6246eb97c2d268a4e9ee2242cb3efc888a0e1d", + "published": "2025-01-15" + }, + { + "version": "0.3.0", + "sha": "1cc79c25241af201b3320bda1a9d2d541b6293b6", + "published": "2025-01-15" + }, + { + "version": "0.3.1", + "sha": "7bbaa71aa88582b9eff1ef5046b6e6cfe641b023", + "published": "2025-01-15" + }, + { + "version": "0.3.2", + "sha": "3b9463cd1cce8c584a4b7ff5c553d519c1e6b973", + "published": "2025-01-16" + }, + { + "version": "0.3.3", + "sha": "a811a459aab34a3192e41649545cf3e4ba7a6657", + "published": "2025-01-16" + }, + { + "version": "0.3.4", + "sha": "9cdbedc18026d9dcafb3d7cc63e8835464e00eb1", + "published": "2025-01-16" + }, + { + "version": "0.3.5", + "sha": "335c6a81e7100d02a196da20a3db869d35633c47", + "published": "2025-01-16" + }, + { + "version": "0.4.0", + "sha": "eb0c66cf2c1283c38d40351c3004da58d0dd1ebf", + "published": "2025-01-16" + }, + { + "version": "0.4.1", + "sha": "f778190127c5606b3bdcb92cc0b10ce59da9cbb7", + "published": "2025-01-17" + }, + { + "version": "0.4.2", + "sha": "94cd3e71eaa0183dd9b15a508c65e496a496a16e", + "published": "2025-01-17" + }, + { + "version": "0.5.0", + "sha": "8f14d5f45ef16f47fadafefd81dd3bc6f72ac5bb", + "published": "2025-01-17" + }, + { + "version": "0.6.0", + "sha": "59664310920665d37d6a020b376285f8f8813f9f", + "published": "2025-01-17" + }, + { + "version": "0.7.0", + "sha": "749a6fcf340bcf5151b3ace7bba8c7377266002a", + "published": "2025-01-17" + }, + { + "version": "0.7.1", + "sha": "038be64a36dfedf5eb709f3e2953019dbf905cb4", + "published": "2025-01-18" + }, + { + "version": "0.7.2-beta.1", + "sha": "0704019ca384cc7f1bb7b95484a5bdab87edf544", + "published": "2025-01-18" + }, + { + "version": "0.7.2-beta.2", + "sha": "bc9328c560185a599e7507dc9c12bed087f3f167", + "published": "2025-01-18" + }, + { + "version": "0.7.2", + "sha": "fa8b4921093e2355caa16a6b3cc21a6cc29491e1", + "published": "2025-01-18" + }, + { + "version": "0.7.3", + "sha": "4f991328be71c647fddd8a490283eda90fa3e380", + "published": "2025-01-18" + }, + { + "version": "0.8.0", + "sha": "02736eda0e6463ca3ea95a24a686756095f1c239", + "published": "2025-01-21" + }, + { + "version": "1.0.0-rc.1", + "sha": "0826ffac1c44a365995f05a6ac95369259c01b58", + "published": "2025-01-22" + }, + { + "version": "1.0.0-rc.2", + "sha": "a85257e4bfeded942d5bf0c320ed3604fc1be90b", + "published": "2025-01-22" + }, + { + "version": "1.0.0", + "sha": "bb99509603457dd1515132b9521cf1d04951f0c1", + "published": "2025-01-22" + }, + { + "version": "1.0.1", + "sha": "c67754a5905386f287237538cd4ef4cb6f6e3e45", + "published": "2025-02-02" + }, + { + "version": "1.0.2", + "sha": "6d1074384ff24eb0c8d2b55867bea44d78c85e87", + "published": "2025-02-02" + }, + { + "version": "1.0.3", + "sha": "68e1e7f2f60676fd66da57b6815199c782665c31", + "published": "2025-02-03" + }, + { + "version": "1.0.4", + "sha": "5120f7c67573009c13ab5510a71e443a60351abe", + "published": "2025-02-04" + }, + { + "version": "2.0.0-beta.1", + "sha": "13a6c93c06cd3d9e2ef8efd3892f3eb8646f3d8c", + "published": "2025-02-26" + }, + { + "version": "2.0.0-rc.1", + "sha": "7a825a41acb314b998b66d34fa7b81f28b11a835", + "published": "2025-02-26" + }, + { + "version": "2.0.0-rc.2", + "sha": "e4e3c46ed9177570aeb52e6dd0fb3976c1a30387", + "published": "2025-02-27" + }, + { + "version": "2.0.0-rc.3", + "sha": "f8566eabc810bb36d70800cc02711875b9261a1b", + "published": "2025-02-27" + }, + { + "version": "2.0.0-rc.4", + "sha": "c36fb1a28243d2c28b1c2cec45964658a9ed05a7", + "published": "2025-02-27" + }, + { + "version": "2.0.0-rc.5", + "sha": "e3868452c772bad689a1223007f61637b8fc3ae3", + "published": "2025-02-27" + }, + { + "version": "2.0.0-rc.6", + "sha": "0bff919508c758350753510f31c61024b3d838ff", + "published": "2025-02-27" + }, + { + "version": "2.0.0-rc.7", + "sha": "ec6e5e9947ec87ade06ac7556143f636eaae623b", + "published": "2025-02-28" + }, + { + "version": "2.0.0-rc.8", + "sha": "37431d4b85a33eb5d0d18aa0b6c22f2d5513a41a", + "published": "2025-02-28" + }, + { + "version": "2.0.0-rc.9", + "sha": "9487f3cc6d220a645c523723fced9b69c75eec36", + "published": "2025-03-03" + }, + { + "version": "2.0.0-rc.10", + "sha": "b25b108c8d972d8b7616d205f0920c22cf7aeb9a", + "published": "2025-03-03" + }, + { + "version": "2.0.0-rc.11", + "sha": "a43c4c1f0a492300925c5a55464da676ea0ba524", + "published": "2025-03-03" + }, + { + "version": "2.0.0-rc.12", + "sha": "0f2802bdf0a1e8a4544eebbb85fc40082b4886cf", + "published": "2025-03-03" + }, + { + "version": "2.0.0-rc.13", + "sha": "03607e4b5a665534135f679656c3164156414ab2", + "published": "2025-03-03" + }, + { + "version": "2.0.0", + "sha": "dcabaf827015d151324d6f20639330b96ae6ee12", + "published": "2025-03-06" + }, + { + "version": "2.1.0", + "sha": "d15d694d956ac38426aafe0380f876874430a433", + "published": "2025-04-08" + }, + { + "version": "2.1.1", + "sha": "53e5d91f8705d4aa02f32b417e0d14f949facc09", + "published": "2025-04-08" + }, + { + "version": "2.1.2", + "sha": "2ed7ef8c4c1371e651bb03794304d37e13490c0f", + "published": "2025-04-08" + }, + { + "version": "2.1.3", + "sha": "74c3f74e2959f0ce9b863130e7e25154f5f0b097", + "published": "2025-04-08" + }, + { + "version": "2.1.4", + "sha": "7b8bc20c30d7ab03dfb708b6d534fde27442d63a", + "published": "2025-04-08" + }, + { + "version": "2.1.5", + "sha": "c4ebd3ddb9a4f90a6c83309b9a49f6ba864e0c68", + "published": "2025-04-09" + }, + { + "version": "3.0.0", + "sha": "7065528d7f58dd1591b078cb418bdfbfab122210", + "published": "2025-06-21" + }, + { + "version": "3.1.0", + "sha": "2afd5540849a701aca095eca9226ac143f3d6f0c", + "published": "2025-06-22" + }, + { + "version": "3.2.0", + "sha": "efa284a6b8f2fce0dacb94f93b94487025360583", + "published": "2025-06-22" + }, + { + "version": "3.2.1", + "sha": "6abb14b2a485660eee48199881cfc5888fc94a72", + "published": "2025-06-23" + }, + { + "version": "3.3.0", + "sha": "b37ab625c6f0047c4e8f969d8b876a681dcc4f83", + "published": "2025-06-24" + }, + { + "version": "3.4.0", + "sha": "991452fe2139a4d7e6d25e5d6b81db6c0b75b99d", + "published": "2025-07-09" + }, + { + "version": "3.4.1", + "sha": "8eca1a6e1b72620c2a4ae1bac3a986318a135d5c", + "published": "2025-07-23" + }, + { + "version": "3.5.0", + "sha": "cee5124064869255db9767a287d497864f5ad607", + "published": "2025-08-02" + }, + { + "version": "3.5.1", + "sha": "a4f5deab1312e6e1b181f58a9ac1c854ff939316", + "published": "2025-11-02" + }, + { + "version": "3.5.2", + "sha": "9860dca632c8a5b7bb276e41d7f88907ce2cdf7f", + "published": "2025-11-04" + }, + { + "version": "3.6.0", + "sha": "85cb5b0f1809081be4d4a96cc399da20c37f05c4", + "published": "2025-11-06" + }, + { + "version": "3.7.0", + "sha": "1becea9e1d14e724c2b3374b75860db18eb11dc6", + "published": "2026-03-19" + }, + { + "version": "3.8.0", + "sha": "6ab400f98eb7e7d99f3baee06813c2b9e19902d7", + "published": "2026-03-23" + }, + { + "version": "3.8.1", + "sha": "f9c45b99e034e75e28b6b2aea0e69aca3c2d3c7c", + "published": "2026-04-29" + }, + { + "version": "4.0.0", + "sha": "665ba46751644adf97c8c57ae57bd3e366822eeb", + "published": "2026-06-03" + }, + { + "version": "4.0.1", + "sha": "3dd13d60cb717a9644556a8c4b45c1fb2b95dcc3", + "published": "2026-06-03" + }, + { + "version": "4.1.0", + "sha": "23784998c8055899fb9876e3a27f1938833a1628", + "published": "2026-06-08" + }, + { + "version": "4.1.1", + "sha": "91a7d979886fec49f88d7ac9ba7a27957e62fdef", + "published": "2026-06-11" + }, + { + "version": "4.1.2", + "sha": "f5e5592c939ad00738cdc9b47eb332f57df3212b", + "published": "2026-07-02" + } +] diff --git a/scripts/tag-historical-releases.mjs b/scripts/tag-historical-releases.mjs new file mode 100644 index 00000000..a742ffd3 --- /dev/null +++ b/scripts/tag-historical-releases.mjs @@ -0,0 +1,63 @@ +#!/usr/bin/env node +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import path from 'node:path' + +const root = path.resolve(import.meta.dirname, '..') +const apply = process.argv.includes('--apply') +const push = process.argv.includes('--push') + +function git(args) { + return execFileSync('git', args, { + cwd: root, + encoding: 'utf-8', + }).trim() +} + +const mapping = JSON.parse( + readFileSync(path.resolve(root, 'scripts/historical-releases.json'), 'utf-8') +) + +const existing = new Set( + git(['tag', '--list', 'v*']).split('\n').filter(Boolean) +) + +let created = 0 +let skipped = 0 +let missingSha = 0 + +for (const entry of mapping) { + const tag = `v${entry.version}` + if (!entry.sha) { + missingSha++ + console.log(`skip ${tag}: no mapped SHA`) + continue + } + if (existing.has(tag)) { + skipped++ + continue + } + if (!apply) { + console.log(`would tag ${tag} -> ${entry.sha}`) + created++ + continue + } + git(['tag', '-a', tag, entry.sha, '-m', tag]) + existing.add(tag) + created++ + console.log(`tagged ${tag} -> ${entry.sha}`) +} + +if (apply && push) { + git(['push', 'origin', '--tags']) + console.log('pushed tags to origin') +} + +console.log( + `${apply ? 'created' : 'would create'} ${created}, skipped ${skipped}, missing SHA ${missingSha}` +) +if (!apply) { + console.log( + 'Re-run with --apply to create annotated tags. Add --push to push them.' + ) +} diff --git a/turbo.json b/turbo.json index ef21be9f..341f3082 100644 --- a/turbo.json +++ b/turbo.json @@ -1,8 +1,37 @@ { - "$schema": "./node_modules/turbo/schema.json", + "$schema": "https://v2-10-12.turborepo.dev/schema.json", + "ui": "tui", + "concurrency": "100%", + "cacheMaxAge": "14d", + "cacheMaxSize": "10GB", "tasks": { + "transit": { + "dependsOn": ["^transit"] + }, + "build": { + "dependsOn": ["^build"], + "inputs": ["$TURBO_DEFAULT$"], + "outputs": [ + "dist/**", + ".next/**", + "!.next/cache/**", + "!.next/dev/**", + ".source/**", + ".output/**" + ] + }, "check-types": { - "cache": false + "dependsOn": ["transit", "permix#build"], + "inputs": [ + "$TURBO_DEFAULT$", + { + "mode": "jit", + "globs": [".source/**"] + } + ] + }, + "test": { + "dependsOn": ["transit", "permix#build"] } } }