diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 6c10ac89..2e27e1ce 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -43,10 +43,10 @@ Closes # ## Checklist -- [ ] My code follows the project's coding style (`pnpm -r run lint` passes). -- [ ] TypeScript compiles without errors (`pnpm -r run typecheck`). +- [ ] My code follows the project's coding style (`npm run lint` passes). +- [ ] TypeScript compiles without errors (`npm run typecheck --workspaces --if-present`). - [ ] I have added or updated tests for the changes I made. -- [ ] All tests pass locally (`pnpm -r run test`). +- [ ] All tests pass locally (`npm run test --workspaces --if-present`). - [ ] I have updated documentation where necessary. - [ ] No new `console.log` or debug statements left in the code. - [ ] Breaking changes are documented in this PR description. diff --git a/.github/scripts/ciScript.js b/.github/scripts/ciScript.js index f8cb346c..21bd910e 100644 --- a/.github/scripts/ciScript.js +++ b/.github/scripts/ciScript.js @@ -1,3 +1,19 @@ +const isTestFile = (file) => /\.(test|spec)\.[jt]sx?$/.test(file); + +const deriveTestFiles = (files) => { + return files.map((file) => { + if (isTestFile(file)) return file; + + const withoutExt = file.replace(/\.[jt]sx?$/, ''); + const parts = withoutExt.split('/'); + const baseName = parts[parts.length - 1]; + const dir = parts.slice(0, -1).join('/'); + + return `${dir}/__tests__/${baseName}.test.ts`; + }); +}; + + module.exports = async ({ github, context, core }) => { const owner = context.repo.owner; const repo = context.repo.repo; @@ -6,9 +22,9 @@ module.exports = async ({ github, context, core }) => { const prState = pr.state; const backendFiles = []; - const backendTests = []; const mobileFiles = []; const webFiles = []; + const dbFiles = []; try { if (prState === 'closed') { @@ -34,60 +50,31 @@ module.exports = async ({ github, context, core }) => { if (fileName.startsWith('apps/backend/')) { backendFiles.push(fileName); - - const relative = fileName.replace('apps/backend/src/', ''); - const baseName = relative - .split('/') - .pop() - ?.replace(/\.(ts|tsx|js|jsx)$/, ''); - - if (baseName) { - backendTests.push(`src/__tests__/${baseName}.test.ts`); - } - } else if (fileName.startsWith('apps/mobile/')) { mobileFiles.push(fileName); } else if (fileName.startsWith('apps/web/')) { webFiles.push(fileName); + }else if(fileName.startsWith('apps/backend/prisma')){ + dbFiles.push(fileName) + }else if(fileName.includes('schema.prisma') || fileName.includes('/migrations/')){ + dbFiles.push(fileName) } }); - console.log({ - backendFiles, - backendTests, - mobileFiles, - webFiles, - }); - - core.setOutput( - "backendFiles", - backendFiles - .map(file => file.replace("apps/backend/", "")) - .join(" ") - ); - - core.setOutput( - "backendTests", - [...new Set(backendTests)].join(" ") - ); - - core.setOutput( - "mobileFiles", - mobileFiles - .map(file => file.replace("apps/mobile/", "")) - .join(" ") - ); + const strippedBackend = backendFiles.map(f => f.replace('apps/backend/', '')); + const strippedMobile = mobileFiles.map(f => f.replace('apps/mobile/', '')); - core.setOutput( - "webFiles", - webFiles - .map(file => file.replace("apps/web/", "")) - .join(" ") - ); + console.log({ backendFiles, mobileFiles, webFiles, dbFiles }); - core.setOutput("backendChanged", backendFiles.length > 0); - core.setOutput("mobileChanged", mobileFiles.length > 0); - core.setOutput("webChanged", webFiles.length > 0); + core.setOutput('backendFiles', strippedBackend.join(' ')); + core.setOutput('mobileFiles', strippedMobile.join(' ')); + core.setOutput('dbFiles', dbFiles.join(' ')); + core.setOutput('webFiles', webFiles.map(f => f.replace('apps/web/', '')).join(' ')); + core.setOutput('backendTestFiles', deriveTestFiles(strippedBackend).join(' ')); + core.setOutput('mobileTestFiles', deriveTestFiles(strippedMobile).join(' ')); + core.setOutput('backendChanged', backendFiles.length > 0); + core.setOutput('mobileChanged', mobileFiles.length > 0); + core.setOutput('webChanged', webFiles.length > 0); } catch (error) { console.error(error); diff --git a/.github/scripts/commentResults.js b/.github/scripts/commentResults.js index 50cd1395..9da18bf7 100644 --- a/.github/scripts/commentResults.js +++ b/.github/scripts/commentResults.js @@ -9,62 +9,59 @@ module.exports = async ({ backendTypecheck, mobileLint, mobileTest, - webCheck, - webBuild + webBuild, + backendLintOutput, + mobileLintOutput, }) => { const owner = context.repo.owner; const repo = context.repo.repo; const prNumber = context.payload.pull_request.number; - const emoji = (status) => { - if (status === 'success') return '✅'; - if (status === 'failure') return '❌'; - if (status === 'skipped') return '⏭️'; - return '⚪'; + const status = (s) => { + if (s === 'success') return 'PASS'; + if (s === 'failure') return 'FAIL'; + if (s === 'skipped') return 'SKIP'; + return '-'; }; - const label = (status) => { - if (!status) return '⚪ unknown'; - return `${emoji(status)} ${status}`; + const lintDetails = (output) => { + if (!output || !output.trim()) return ''; + return `\n
\nView lint errors\n\n\`\`\`\n${output.trim()}\n\`\`\`\n
`; }; - const anyFailure = [ - backend, - mobile, - web - ].includes('failure'); - - const title = anyFailure - ? '❌ Some checks failed' - : '✅ CI completed'; - + const anyFailure = [backend, mobile, web].includes('failure'); + const title = anyFailure ? 'CI — Checks Failed' : 'CI — All Checks Passed'; const timestamp = new Date().toUTCString(); - const body = `## CI Results — ${title} + const body = `## ${title} + +### Backend — ${status(backend)} -### 🖥️ Backend (${label(backend)}) -| Check | Status | +| Check | Result | |---|---| -| Lint | ${label(backendLint)} | -| Test | ${label(backendTest)} | -| Typecheck | ${label(backendTypecheck)} | +| Lint | ${status(backendLint)} | +| Test | ${status(backendTest)} | +| Typecheck | ${status(backendTypecheck)} | +${backendLint === 'failure' ? lintDetails(backendLintOutput) : ''} -### 📱 Mobile (${label(mobile)}) -| Check | Status | +### Mobile — ${status(mobile)} + +| Check | Result | |---|---| -| Lint | ${label(mobileLint)} | -| Test | ${label(mobileTest)} | +| Lint | ${status(mobileLint)} | +| Test | ${status(mobileTest)} | +${mobileLint === 'failure' ? lintDetails(mobileLintOutput) : ''} + +### Web — ${status(web)} -### 🌐 Web (${label(web)}) -| Check | Status | +| Check | Result | |---|---| -| Check | ${label(webCheck)} | -| Build | ${label(webBuild)} | +| Build | ${status(webBuild)} | --- -🕐 Last updated: \`${timestamp}\``; +Last updated: \`${timestamp}\``; - const COMMENT_MARKER = '## CI Results —'; + const COMMENT_MARKER = '## CI —'; try { const comments = await github.paginate( diff --git a/.github/scripts/discordPinReminder.js b/.github/scripts/discordPinReminder.js index d5724578..6751f7c5 100644 --- a/.github/scripts/discordPinReminder.js +++ b/.github/scripts/discordPinReminder.js @@ -3,35 +3,36 @@ module.exports = async ({ github, context }) => { const ignoreUsers = [ 'ShantKhatri', 'Harxhit', - 'blankirigaya' - ] + 'blankirigaya', + ]; + try { - // Only continue if merged - if (!pr || !pr.merged) { - console.log('PR not merged.'); - return; - } - - const prNumber = pr.number; - const contributor = pr.user.login; + if (!pr || !pr.merged) { + console.log('PR not merged.'); + return; + } + + const prNumber = pr.number; + const contributor = pr.user.login; + + if (ignoreUsers.includes(contributor)) { + console.log(`Ignoring PR #${prNumber} by ${contributor}`); + return; + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: `Congratulations @${contributor} on getting PR #${prNumber} merged! + +Thank you for your contribution to the project. - if(ignoreUsers.includes(contributor)){ - console.log(`Ignoring PR #${prNumber} by ${contributor}`); - return; - } - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - body: `Congratulations @${contributor} on getting PR #${prNumber} merged! +To receive the appropriate GSSoC labels and recognition, please mention @Harxhit in the **#get-labels** channel on our Discord server and share your merged PR link.`, + }); - Thank you for your contribution. Please mention @Harxhit in our Discord server to receive the appropriate GSSoC labels and recognition. - ` - }); - - console.log(`Comment added to PR #${prNumber}`); + console.log(`Comment added to PR #${prNumber}`); } catch (error) { - console.error(error) + console.error(error); } -}; +}; \ No newline at end of file diff --git a/.github/scripts/triageIssue.js b/.github/scripts/triageIssue.js new file mode 100644 index 00000000..b10f06e4 --- /dev/null +++ b/.github/scripts/triageIssue.js @@ -0,0 +1,151 @@ +module.exports = async({github, context}) => { + const owner = context.repo.owner; + const repo = context.repo.repo; + const issue = context.payload.issue; + const issueNumber = issue.number; + const issueDescription = issue.body; + const username = issue.user.login + + if(context.eventName === 'issues'){ + try { + if(!issueDescription || !issueDescription.trim()){ + return await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: `Hi @${username}, + + Thanks for opening this issue. + + It looks like the issue description is currently missing. + + Please provide: + - A brief summary of the problem + - Expected behavior + - Actual behavior (if applicable) + - Any relevant screenshots, logs, or context + + This helps the team understand, prioritize, and route the issue correctly. + + Thank you! + + ` + }) + } + + return await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: `Hi @${username}, + + Thanks for opening this issue. + + Please reply with one of the following areas so the issue can be routed to the appropriate team member: + + - /backend + - /web + - /mobile + - /devops + + Once an area is selected, the corresponding label will be added automatically.` + }) + + } catch (error) { + console.error(error) + } + }else if(context.eventName === 'issue_comment'){ + if (context.payload.comment.user.type === 'Bot' || context.payload.comment.user.login === 'github-actions[bot]') { + return; + } + + const comment = (context.payload.comment.body || '').trim().toLowerCase(); + const existingLabels = (issue.labels || []).map(label => label.name); + + if ( + existingLabels.includes('backend') || + existingLabels.includes('web') || + existingLabels.includes('mobile') || + existingLabels.includes('devops') + ) { + return; + } + + if (!['/backend', '/web', '/mobile', '/devops'].includes(comment)) { + return; + } + if(comment === '/backend'){ + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: issueNumber, + labels: ['backend'] + + }) + return await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: `The issue has been classified as **backend**. + +@Harxhit, please review and triage this issue when available. + +The **backend** label has been applied and the issue has been routed accordingly.` + }) + }else if(comment === '/web'){ + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: issueNumber, + labels: ['web'] + + }) + return await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: `The issue has been classified as **web**. + +@ShantKhatri, please review and triage this issue when available. + +The **web** label has been applied and the issue has been routed accordingly.` + }) + }else if(comment === '/mobile'){ + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: issueNumber, + labels: ['mobile'] + + }) + return await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: `The issue has been classified as **mobile**. + +@blankirigaya, please review and triage this issue when available. + +The **mobile** label has been applied and the issue has been routed accordingly.` + }) + }else if(comment === '/devops'){ + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: issueNumber, + labels: ['devops'] + + }) + return await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: `The issue has been classified as **devops**. + +@ShantKhatri, please review and triage this issue when available. + +The **devops** label has been applied and the issue has been routed accordingly.` + }) + } + } +} \ No newline at end of file diff --git a/.github/scripts/triagePr.js b/.github/scripts/triagePr.js new file mode 100644 index 00000000..f0593ed0 --- /dev/null +++ b/.github/scripts/triagePr.js @@ -0,0 +1,116 @@ +module.exports = async ({github, context}) => { + const owner = context.repo.owner; + const repo = context.repo.repo; + const pr = context.payload.pull_request; + const prNumber = pr.number; + const username = pr.user.login; + + const backendFiles = []; + const webFiles = []; + const mobileFiles = []; + const devopsFiles = []; + + const labels = ['gssoc:approved']; + let primaryArea = null; + let reviewer = null; + + try { + const changedFiles = await github.paginate( + github.rest.pulls.listFiles, + { + owner, + repo, + pull_number: prNumber + } + ); + + changedFiles.forEach((file) => { + const fileName = file.filename; + + if(fileName.startsWith('apps/backend/')){ + backendFiles.push(fileName); + }else if(fileName.startsWith('apps/web/')){ + webFiles.push(fileName); + }else if(fileName.startsWith('apps/mobile/')){ + mobileFiles.push(fileName) + }else if(fileName.startsWith('.github/') || fileName.startsWith('infra/') || fileName.startsWith('terraform/')){ + devopsFiles.push(fileName) + } + }) + + + if(backendFiles.length > 0){ + labels.push('backend') + + if(!primaryArea){ + primaryArea = 'backend'; + reviewer = '@Harxhit' + } + } + if(mobileFiles.length > 0){ + labels.push('mobile'); + if(!primaryArea){ + primaryArea = 'mobile'; + reviewer = '@blankirigaya' + } + } + if(webFiles.length > 0){ + labels.push('web'); + if(!primaryArea){ + primaryArea = 'web'; + reviewer = '@ShantKhatri' + } + } + if(devopsFiles.length > 0){ + labels.push('devops') + if(!primaryArea){ + primaryArea = 'devops' + reviewer = '@ShantKhatri' + } + } + + if(labels.length === 0){ + return; + } + + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: prNumber, + labels + }); + const body = `Hi @${username}, + +Thanks for opening this pull request. + +This PR has been automatically classified based on the files modified. + +### Applied Labels + +${labels.map(label => `- ${label}`).join('\n')} + +### Primary Review Area + +* ${primaryArea} + +### Reviewer + +${reviewer} has been identified as the primary reviewer for this pull request. + +If you have any questions regarding the affected area or implementation details, feel free to reach out to the assigned reviewer. + +Thank you for your contribution! `; + + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body: body + }); + + } catch (error) { + console.error(error) + } + +} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45b5f7af..71aedeb6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,16 +12,21 @@ jobs: runs-on: ubuntu-latest outputs: - backendChanged: ${{ steps.detect.outputs.backendChanged }} - mobileChanged: ${{ steps.detect.outputs.mobileChanged }} - webChanged: ${{ steps.detect.outputs.webChanged }} - backendFiles: ${{ steps.detect.outputs.backendFiles }} - mobileFiles: ${{ steps.detect.outputs.mobileFiles }} - webFiles: ${{ steps.detect.outputs.webFiles }} - backendTests: ${{ steps.detect.outputs.backendTests }} + backendChanged: ${{ steps.detect.outputs.backendChanged }} + mobileChanged: ${{ steps.detect.outputs.mobileChanged }} + webChanged: ${{ steps.detect.outputs.webChanged }} + backendFiles: ${{ steps.detect.outputs.backendFiles }} + mobileFiles: ${{ steps.detect.outputs.mobileFiles }} + webFiles: ${{ steps.detect.outputs.webFiles }} + backendTestFiles: ${{ steps.detect.outputs.backendTestFiles }} + mobileTestFiles: ${{ steps.detect.outputs.mobileTestFiles }} + dbFiles: ${{ steps.detect.outputs.dbFiles }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + ref: ${{ github.event.pull_request.head.sha }} + - name: Detect changed files id: detect @@ -37,39 +42,49 @@ jobs: if: needs.detect-changes.outputs.backendChanged == 'true' runs-on: ubuntu-latest - outputs: - backend_lint: ${{ steps.backend_lint.outcome }} - backend_test: ${{ steps.backend_test.outcome }} - backend_typecheck: ${{ steps.backend_typecheck.outcome }} + outputs: + lint_result: ${{ steps.backend_lint.outcome }} + test_result: ${{ steps.backend_test.outcome }} + typecheck_result: ${{ steps.backend_typecheck.outcome }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + ref: ${{ github.event.pull_request.head.sha }} - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: node-version: 22 - - uses: pnpm/action-setup@v6.0.8 + - name: Install backend dependencies + run: npm --prefix apps/backend install - - run: pnpm install + - name: DB migration check + if: needs.detect-changes.outputs.dbFiles != '' + continue-on-error: true + run: npm run db:migrate - name: Backend lint id: backend_lint - continue-on-error: true - run: cd apps/backend && pnpm eslint ${{ needs.detect-changes.outputs.backendFiles }} + continue-on-error: true + run: cd apps/backend && npx eslint ${{ needs.detect-changes.outputs.backendFiles }} - name: Backend test id: backend_test + if: needs.detect-changes.outputs.backendTestFiles != '' continue-on-error: true - run: cd apps/backend && pnpm test ${{ needs.detect-changes.outputs.backendTests }} + run: npm --prefix apps/backend run test -- --passWithNoTests ${{ needs.detect-changes.outputs.backendTestFiles }} - name: Backend typecheck id: backend_typecheck continue-on-error: true - run: cd apps/backend && pnpm typecheck ${{ needs.detect-changes.outputs.backendFiles }} + run: npm --prefix apps/backend run typecheck - - name: Fail backend if checks failed - if: steps.backend_lint.outcome == 'failure' || steps.backend_test.outcome == 'failure' || steps.backend_typecheck.outcome == 'failure' + - name: Fail job if any check failed + if: > + steps.backend_lint.outcome == 'failure' || + steps.backend_test.outcome == 'failure' || + steps.backend_typecheck.outcome == 'failure' run: exit 1 web-ci: @@ -78,32 +93,27 @@ jobs: runs-on: ubuntu-latest outputs: - web_check: ${{ steps.web_check.outcome }} - web_build: ${{ steps.web_build.outcome }} + build_result: ${{ steps.web_build.outcome }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + ref: ${{ github.event.pull_request.head.sha }} - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: node-version: 22 - - uses: pnpm/action-setup@v6.0.8 - - - run: pnpm install - - - name: Web check - id: web_check - continue-on-error: true - run: cd apps/web && pnpm check + - name: Install web dependencies + run: npm --prefix apps/web install - name: Web build id: web_build continue-on-error: true - run: cd apps/web && pnpm build + run: npm --prefix apps/web run build - - name: Fail web if checks failed - if: steps.web_check.outcome == 'failure' || steps.web_build.outcome == 'failure' + - name: Fail job if any check failed + if: steps.web_build.outcome == 'failure' run: exit 1 mobile-ci: @@ -112,32 +122,39 @@ jobs: runs-on: ubuntu-latest outputs: - mobile_lint: ${{ steps.mobile_lint.outcome }} - mobile_test: ${{ steps.mobile_test.outcome }} + lint_result: ${{ steps.mobile_lint.outcome }} + test_result: ${{ steps.mobile_test.outcome }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + ref: ${{ github.event.pull_request.head.sha }} - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: node-version: 22 - - uses: pnpm/action-setup@v6.0.8 + - name: Install shared dependencies + run: npm --prefix packages/shared install - - run: pnpm install + - name: Install mobile dependencies + run: npm --prefix apps/mobile install - name: Mobile lint id: mobile_lint continue-on-error: true - run: cd apps/mobile && pnpm eslint ${{ needs.detect-changes.outputs.mobileFiles }} + run: cd apps/mobile && npx eslint ${{ needs.detect-changes.outputs.mobileFiles }} - name: Mobile test id: mobile_test + if: needs.detect-changes.outputs.mobileTestFiles != '' continue-on-error: true - run: cd apps/mobile && pnpm test + run: npm --prefix apps/mobile run test -- --passWithNoTests ${{ needs.detect-changes.outputs.mobileTestFiles }} - - name: Fail mobile if checks failed - if: steps.mobile_lint.outcome == 'failure' || steps.mobile_test.outcome == 'failure' + - name: Fail job if any check failed + if: > + steps.mobile_lint.outcome == 'failure' || + steps.mobile_test.outcome == 'failure' run: exit 1 comment-results: @@ -157,22 +174,16 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const script = require('./.github/scripts/commentResults.js'); - await script({ github, context, - - backend: '${{ needs.backend-ci.result }}', - web: '${{ needs.web-ci.result }}', - mobile: '${{ needs.mobile-ci.result }}', - - backendLint: '${{ needs.backend-ci.outputs.backend_lint }}', - backendTest: '${{ needs.backend-ci.outputs.backend_test }}', - backendTypecheck: '${{ needs.backend-ci.outputs.backend_typecheck }}', - - mobileLint: '${{ needs.mobile-ci.outputs.mobile_lint }}', - mobileTest: '${{ needs.mobile-ci.outputs.mobile_test }}', - - webCheck: '${{ needs.web-ci.outputs.web_check }}', - webBuild: '${{ needs.web-ci.outputs.web_build }}' - }); \ No newline at end of file + backend: '${{ needs.backend-ci.result }}', + web: '${{ needs.web-ci.result }}', + mobile: '${{ needs.mobile-ci.result }}', + backendLint: '${{ needs.backend-ci.outputs.lint_result }}', + backendTest: '${{ needs.backend-ci.outputs.test_result }}', + backendTypecheck: '${{ needs.backend-ci.outputs.typecheck_result }}', + webBuild: '${{ needs.web-ci.outputs.build_result }}', + mobileLint: '${{ needs.mobile-ci.outputs.lint_result }}', + mobileTest: '${{ needs.mobile-ci.outputs.test_result }}', + }); \ No newline at end of file diff --git a/.github/workflows/triageIssue.yml b/.github/workflows/triageIssue.yml new file mode 100644 index 00000000..98162d4c --- /dev/null +++ b/.github/workflows/triageIssue.yml @@ -0,0 +1,27 @@ +name: Issue triage + +on: + issues: + types: [opened] + issue_comment: + types: [created, edited] + + +permissions: + issues: write + +jobs: + assignLabel: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 + + - name: Triage issue + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 #v9.0.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const script = require('./.github/scripts/triageIssue.js'); + await script({ github, context }); diff --git a/.github/workflows/triagePr.yml b/.github/workflows/triagePr.yml new file mode 100644 index 00000000..99344773 --- /dev/null +++ b/.github/workflows/triagePr.yml @@ -0,0 +1,25 @@ +name: Pull request triage + +on: + pull_request_target: + types: [opened] + +permissions: + pull-requests: write + issues: write + +jobs: + assignLabel: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 + + - name: Triage pull request + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 #v9.0.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const script = require('./.github/scripts/triagePr.js'); + await script({ github, context }); \ No newline at end of file diff --git a/.github/workflows/uat.yml b/.github/workflows/uat.yml new file mode 100644 index 00000000..5b310f7e --- /dev/null +++ b/.github/workflows/uat.yml @@ -0,0 +1,135 @@ +name: UAT Deploy + +on: + push: + branches: [main] + +jobs: + detect-changes: + runs-on: ubuntu-latest + outputs: + backend: ${{ steps.changes.outputs.backend }} + steps: + - uses: actions/checkout@v4 + + - uses: dorny/paths-filter@v3 + id: changes + with: + filters: | + backend: + - 'apps/backend/**' + + backend-deploy: + needs: detect-changes + if: needs.detect-changes.outputs.backend == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + + steps: + - name: Checkout app repo + uses: actions/checkout@v4 + + - name: Checkout infra repo + uses: actions/checkout@v4 + with: + repository: Dev-Card/devcard-infra + path: infra + token: ${{ secrets.INFRA_REPO_TOKEN }} + + - name: Authenticate to GCP + uses: google-github-actions/auth@v2 + with: + workload_identity_provider: ${{ secrets.WIF_PROVIDER }} + service_account: ${{ secrets.WIF_SERVICE_ACCOUNT }} + + - name: Setup gcloud + uses: google-github-actions/setup-gcloud@v2 + + - name: Configure Docker for Artifact Registry + run: gcloud auth configure-docker asia-south1-docker.pkg.dev + + - name: Set image tag + id: tag + run: echo "sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: apps/backend/package-lock.json + + - name: Install dependencies + working-directory: apps/backend + run: npm ci + + # TODO: Once tests are fixed, uncomment the following lines + # - name: Run tests + # working-directory: apps/backend + # run: npm test + + - name: Build and push Docker image + run: | + docker build \ + -f docker/backend.Dockerfile \ + -t asia-south1-docker.pkg.dev/devcard-prod/devcard/backend:${{ steps.tag.outputs.sha }} \ + . + docker push asia-south1-docker.pkg.dev/devcard-prod/devcard/backend:${{ steps.tag.outputs.sha }} + + - name: Get GKE credentials + uses: google-github-actions/get-gke-credentials@v2 + with: + cluster_name: devcard-cluster + location: asia-south1 + + - name: Run Prisma migrations + run: | + cat <= 20 -- **pnpm** >= 9 +- **npm** >= 10 (bundled with Node.js) - **Docker** & Docker Compose - **React Native** dev environment — follow the [official setup guide](https://reactnative.dev/docs/environment-setup) @@ -25,7 +25,11 @@ git clone https://github.com/Dev-Card/DevCard.git cd devcard # 2. Install dependencies -pnpm install +npm install # root (orchestrator) +npm --prefix packages/shared install # shared types/utils +npm --prefix apps/backend install # backend API +npm --prefix apps/web install # web app +npm --prefix apps/mobile install # mobile app (if working on mobile) # 3. Start PostgreSQL + Redis docker compose up -d @@ -35,34 +39,34 @@ cp .env.example .env # Edit .env with your OAuth credentials # 5. Run database migrations and seed -pnpm db:migrate -pnpm db:seed +npm run db:migrate +npm run db:seed # 6. Start development -pnpm dev:backend # Backend API on :3000 -pnpm dev:mobile # React Native app +npm run dev:backend # Backend API on :3000 +npm run dev:mobile # React Native app ``` ### Running Tests -This project uses `pnpm` to run tests across different parts of the codebase. +This project uses `npm` to run tests across different parts of the codebase. #### Run all tests -To execute all available tests: +To execute backend tests: ```bash -pnpm -r test +npm run test ``` #### apps/backend The backend uses Vitest: ```bash -pnpm --filter @devcard/backend test -pnpm --filter @devcard/backend test:watch +npm --prefix apps/backend run test +npm --prefix apps/backend run test:watch ``` #### apps/mobile The mobile app uses Jest: ```bash -pnpm --filter @devcard/mobile test +npm --prefix apps/mobile run test ``` #### apps/web Currently, the web app does not define a test script. @@ -84,7 +88,7 @@ devcard/ ## Coding Standards - **TypeScript** for all new code -- **ESLint + Prettier** for formatting (run `pnpm lint` before committing) +- **ESLint + Prettier** for formatting (run `npm run lint` before committing) - **Conventional Commits** for commit messages (`feat:`, `fix:`, `docs:`, `chore:`) - Write tests for new features and bug fixes @@ -92,8 +96,8 @@ devcard/ 1. Create a feature branch from `main`: `git checkout -b feat/your-feature` 2. Make your changes with clear, descriptive commits -3. Ensure all tests pass: `pnpm test` -4. Ensure linting passes: `pnpm lint` +3. Ensure all tests pass: `npm run test` +4. Ensure linting passes: `npm run lint` 5. Open a PR against `main` with a clear description of the change 6. Wait for review — maintainers will respond within 48 hours diff --git a/README.md b/README.md index 26261279..2690181e 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,22 @@ --- +## Table of Contents +- [The Problem](#the-problem) +- [The Solution](#the-solution) +- [Features](#features) +- [Quick Start](#quick-start) +- [Architecture](#architecture) +- [Tech Stack](#tech-stack) +- [API Endpoints](#api-endpoints) +- [Good First Issues](#good-first-issues) +- [Contributing](#contributing) +- [Contributors](#contributors) +- [Project Support](#project-support) +- [License](#license) + +--- + ## The Problem At every developer meetup, hackathon, or conference, the same friction plays out: @@ -52,8 +68,7 @@ Each exchange is manual, error-prone, and slow. DevCard fixes this. ### Prerequisites -- Node.js >= 20 -- pnpm >= 9 +- Node.js >= 20 (includes npm) - Docker & Docker Compose - React Native development environment ([setup guide](https://reactnative.dev/docs/environment-setup)) @@ -65,7 +80,11 @@ git clone https://github.com/Dev-Card/DevCard.git cd devcard # Install dependencies -pnpm install +npm install # root orchestrator +npm --prefix packages/shared install # shared types/utils +npm --prefix apps/backend install # backend API +npm --prefix apps/web install # web app +npm --prefix apps/mobile install # mobile app (if needed) # Start infrastructure (PostgreSQL + Redis) docker compose up -d @@ -79,16 +98,16 @@ cp .env.example .env # Paste the generated values into your .env file. Never use placeholders in production. # Run database migrations -pnpm db:migrate +npm run db:migrate # Seed sample data -pnpm db:seed +npm run db:seed # Start the backend -pnpm dev:backend +npm run dev:backend # In another terminal — start the mobile app -pnpm dev:mobile +npm run dev:mobile ``` ## Architecture @@ -96,13 +115,13 @@ pnpm dev:mobile ``` devcard/ ├── apps/ -│ ├── backend/ # Fastify + TypeScript API -│ ├── mobile/ # React Native (Bare) mobile app -│ └── web/ # SvelteKit web backup +│ ├── backend/ # Fastify + TypeScript API (independent npm) +│ ├── mobile/ # React Native (Bare) mobile app (independent npm) +│ └── web/ # Vite + React web app (independent npm) ├── packages/ │ └── shared/ # Shared types, platform registry, utils ├── docker-compose.yml # PostgreSQL + Redis -└── pnpm-workspace.yaml # Monorepo config +└── package.json # Root orchestrator (npm scripts) ``` ### Tech Stack diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..40ae32ea --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,42 @@ +# Security Policy + +## Supported Versions + +The following versions of **DevCard** are currently supported with security updates: + +| Version | Supported | +| ------- | ------------------ | +| main | ✅ Yes | + +## Reporting a Vulnerability + +If you discover a security vulnerability in **DevCard**, please **do not open a public issue**. + +Instead, report it responsibly by: + +- 📧 Reaching out to the maintainer directly via their [GitHub profile](https://github.com/Dev-Card) +- 💬 Sending a private message through GitHub's messaging or social links listed in the profile + +### What to include in your report: +- A clear description of the vulnerability +- Steps to reproduce the issue +- Potential impact assessment +- Any suggested fix (optional but appreciated) + +## Response Timeline + +| Action | Timeframe | +| ----------------------------- | ----------------- | +| Acknowledgement of report | Within 48 hours | +| Status update | Within 7 days | +| Patch / fix release | Within 30 days | + +## Responsible Disclosure + +We follow a **responsible disclosure** policy. Please give us adequate time to patch the issue before any public disclosure. We deeply appreciate security researchers who help keep **DevCard** safe. 🙏 + +## References + +- [DevCard Repository](https://github.com/Dev-Card/DevCard) +- [GitHub Security Advisories](https://docs.github.com/en/code-security/security-advisories) +- [Adding a Security Policy to your repo](https://docs.github.com/en/code-security/getting-started/adding-a-security-policy-to-your-repository) diff --git a/apps/backend/package-lock.json b/apps/backend/package-lock.json new file mode 100644 index 00000000..832b4eee --- /dev/null +++ b/apps/backend/package-lock.json @@ -0,0 +1,6463 @@ +{ + "name": "@devcard/backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@devcard/backend", + "version": "1.0.0", + "hasInstallScript": true, + "dependencies": { + "@devcard/shared": "file:../../packages/shared", + "@fastify/cookie": "^11.0.0", + "@fastify/cors": "^10.0.0", + "@fastify/helmet": "^12.0.0", + "@fastify/jwt": "^9.0.0", + "@fastify/multipart": "^9.0.0", + "@fastify/rate-limit": "^10.3.0", + "@fastify/static": "^8.0.0", + "@prisma/client": "^6.0.0", + "dotenv": "^16.4.0", + "fastify": "^5.0.0", + "fastify-plugin": "^5.0.0", + "ioredis": "^5.4.0", + "qrcode": "^1.5.0", + "zod": "^3.23.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/qrcode": "^1.5.0", + "eslint": "^10.4.0", + "eslint-import-resolver-typescript": "^4.4.4", + "eslint-plugin-import-x": "^4.16.2", + "eslint-plugin-n": "^18.0.1", + "eslint-plugin-promise": "^7.3.0", + "eslint-plugin-security": "^4.0.0", + "eslint-plugin-unicorn": "^64.0.0", + "pino-pretty": "^13.1.3", + "prisma": "^6.0.0", + "tsx": "^4.0.0", + "typescript": "^5.4.0", + "typescript-eslint": "^8.59.3", + "vitest": "^2.0.0" + } + }, + "../../packages/shared": { + "name": "@devcard/shared", + "version": "1.0.0", + "devDependencies": { + "typescript": "^5.4.0", + "vitest": "^2.0.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@devcard/shared": { + "resolved": "../../packages/shared", + "link": true + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@fastify/accept-negotiator": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz", + "integrity": "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/ajv-compiler/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@fastify/ajv-compiler/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/@fastify/busboy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", + "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", + "license": "MIT" + }, + "node_modules/@fastify/cookie": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.0.2.tgz", + "integrity": "sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "cookie": "^1.0.0", + "fastify-plugin": "^5.0.0" + } + }, + "node_modules/@fastify/cors": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-10.1.0.tgz", + "integrity": "sha512-MZyBCBJtII60CU9Xme/iE4aEy8G7QpzGR8zkdXZkDFt7ElEMachbE61tfhAG/bvSaULlqlf0huMT12T7iqEmdQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^5.0.0", + "mnemonist": "0.40.0" + } + }, + "node_modules/@fastify/deepmerge": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-3.2.1.tgz", + "integrity": "sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", + "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^6.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/helmet": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@fastify/helmet/-/helmet-12.0.1.tgz", + "integrity": "sha512-kkjBcedWwdflRThovGuvN9jB2QQLytBqArCFPdMIb7o2Fp0l/H3xxYi/6x/SSRuH/FFt9qpTGIfJz2bfnMrLqA==", + "license": "MIT", + "dependencies": { + "fastify-plugin": "^5.0.0", + "helmet": "^7.1.0" + } + }, + "node_modules/@fastify/jwt": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/@fastify/jwt/-/jwt-9.1.0.tgz", + "integrity": "sha512-CiGHCnS5cPMdb004c70sUWhQTfzrJHAeTywt7nVw6dAiI0z1o4WRvU94xfijhkaId4bIxTCOjFgn4sU+Gvk43w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "@lukeed/ms": "^2.0.2", + "fast-jwt": "^5.0.0", + "fastify-plugin": "^5.0.0", + "steed": "^1.1.3" + } + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/multipart": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@fastify/multipart/-/multipart-9.4.0.tgz", + "integrity": "sha512-Z404bzZeLSXTBmp/trCBuoVFX28pM7rhv849Q5TsbTFZHuk1lc4QjQITTPK92DKVpXmNtJXeHSSc7GYvqFpxAQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^3.0.0", + "@fastify/deepmerge": "^3.0.0", + "@fastify/error": "^4.0.0", + "fastify-plugin": "^5.0.0", + "secure-json-parse": "^4.0.0" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@fastify/rate-limit": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-10.3.0.tgz", + "integrity": "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "fastify-plugin": "^5.0.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/@fastify/send": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz", + "integrity": "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "escape-html": "~1.0.3", + "fast-decode-uri-component": "^1.0.1", + "http-errors": "^2.0.0", + "mime": "^3" + } + }, + "node_modules/@fastify/static": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@fastify/static/-/static-8.3.0.tgz", + "integrity": "sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/accept-negotiator": "^2.0.0", + "@fastify/send": "^4.0.0", + "content-disposition": "^0.5.4", + "fastify-plugin": "^5.0.0", + "fastq": "^1.17.1", + "glob": "^11.0.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@ioredis/commands": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz", + "integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==", + "license": "MIT" + }, + "node_modules/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@package-json/types": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@package-json/types/-/types-0.0.12.tgz", + "integrity": "sha512-uu43FGU34B5VM9mCNjXCwLaGHYjXdNincqKLaraaCW+7S2+SmiBg1Nv8bPnmschrIfZmfKNY9f3fC376MRrObw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@prisma/client": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz", + "integrity": "sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/config": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz", + "integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "c12": "3.1.0", + "deepmerge-ts": "7.1.5", + "effect": "3.21.0", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz", + "integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz", + "integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/fetch-engine": "6.19.3", + "@prisma/get-platform": "6.19.3" + } + }, + "node_modules/@prisma/engines-version": { + "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz", + "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz", + "integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/get-platform": "6.19.3" + } + }, + "node_modules/@prisma/get-platform": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz", + "integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", + "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", + "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", + "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", + "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", + "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", + "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", + "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", + "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", + "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz", + "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz", + "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz", + "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz", + "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz", + "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz", + "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz", + "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz", + "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz", + "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz", + "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz", + "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz", + "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz", + "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz", + "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz", + "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz", + "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz", + "integrity": "sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.60.1", + "@typescript-eslint/type-utils": "8.60.1", + "@typescript-eslint/utils": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.60.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.1.tgz", + "integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.60.1", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.1.tgz", + "integrity": "sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.60.1", + "@typescript-eslint/types": "^8.60.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.1.tgz", + "integrity": "sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.1.tgz", + "integrity": "sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.1.tgz", + "integrity": "sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1", + "@typescript-eslint/utils": "8.60.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.1.tgz", + "integrity": "sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.1.tgz", + "integrity": "sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.60.1", + "@typescript-eslint/tsconfig-utils": "8.60.1", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.1.tgz", + "integrity": "sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.60.1", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.1.tgz", + "integrity": "sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz", + "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.33", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", + "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/builtin-modules": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-5.2.0.tgz", + "integrity": "sha512-02yxLeyxF4dNl6SlY6/5HfRSrSdZ/sCPoxy2kZNP5dZZX8LSAD9aE2gtJIUgWrsQTiMPl3mxESyrobSwvRGisQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/c12": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", + "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.3", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^16.6.1", + "exsolve": "^1.0.7", + "giget": "^2.0.0", + "jiti": "^2.4.2", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^1.0.0", + "pkg-types": "^2.2.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, + "node_modules/clean-regexp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/clean-regexp/-/clean-regexp-1.0.0.tgz", + "integrity": "sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/clean-regexp/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz", + "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/comment-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.7.tgz", + "integrity": "sha512-0h+uSNtQGW3D98eQt3jJ8L06Fves8hncB4V/PKdw/Qb8Hnk19VaKuTr55UNRYiSoVa7WwrFls+rh3ux9agmkeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/core-js-compat": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", + "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/effect": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz", + "integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.367", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.367.tgz", + "integrity": "sha512-4Mk/mrynCNQ+atY40D3UpmhLWB6AHMbYMlIrPhHcMF6x0L7O0b052FCAsxw1LlaR++UFuNg3D/A6XCuGDa0guQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.22.2", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.2.tgz", + "integrity": "sha512-0rxICaFZ7NQho/sHely2bvOPRP0Eu2B0NZ9zM54YvRvWMn7jfz3DmnOZDR9LlXDdDcqntAVc6Hfy4gr/tdH/Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "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" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.1.tgz", + "integrity": "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-compat-utils": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/eslint-compat-utils/-/eslint-compat-utils-0.5.1.tgz", + "integrity": "sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "eslint": ">=6.0.0" + } + }, + "node_modules/eslint-import-context": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/eslint-import-context/-/eslint-import-context-0.1.9.tgz", + "integrity": "sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-tsconfig": "^4.10.1", + "stable-hash-x": "^0.2.0" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-context" + }, + "peerDependencies": { + "unrs-resolver": "^1.0.0" + }, + "peerDependenciesMeta": { + "unrs-resolver": { + "optional": true + } + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-4.4.5.tgz", + "integrity": "sha512-nbE5XLph6TLtGYcu/U6e6ZVXyKBhbDWK5cLGk76eJ7NdZpwf1P9EFkpt1Z01mNZNrrilsAYWKH6zUkL4reoXbw==", + "dev": true, + "license": "ISC", + "dependencies": { + "debug": "^4.4.1", + "eslint-import-context": "^0.1.8", + "get-tsconfig": "^4.10.1", + "is-bun-module": "^2.0.0", + "stable-hash-x": "^0.2.0", + "tinyglobby": "^0.2.14", + "unrs-resolver": "^1.7.11" + }, + "engines": { + "node": "^16.17.0 || >=18.6.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-es-x": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-es-x/-/eslint-plugin-es-x-7.8.0.tgz", + "integrity": "sha512-7Ds8+wAAoV3T+LAKeu39Y5BzXCrGKrcISfgKEqTS4BDN8SFEDQd0S43jiQ8vIa3wUKD07qitZdfzlenSi8/0qQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/ota-meshi", + "https://opencollective.com/eslint" + ], + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.1.2", + "@eslint-community/regexpp": "^4.11.0", + "eslint-compat-utils": "^0.5.1" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": ">=8" + } + }, + "node_modules/eslint-plugin-import-x": { + "version": "4.16.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-import-x/-/eslint-plugin-import-x-4.16.2.tgz", + "integrity": "sha512-rM9K8UBHcWKpzQzStn1YRN2T5NvdeIfSVoKu/lKF41znQXHAUcBbYXe5wd6GNjZjTrP7viQ49n1D83x/2gYgIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@package-json/types": "^0.0.12", + "@typescript-eslint/types": "^8.56.0", + "comment-parser": "^1.4.1", + "debug": "^4.4.1", + "eslint-import-context": "^0.1.9", + "is-glob": "^4.0.3", + "minimatch": "^9.0.3 || ^10.1.2", + "semver": "^7.7.2", + "stable-hash-x": "^0.2.0", + "unrs-resolver": "^1.9.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-import-x" + }, + "peerDependencies": { + "@typescript-eslint/utils": "^8.56.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "eslint-import-resolver-node": "*" + }, + "peerDependenciesMeta": { + "@typescript-eslint/utils": { + "optional": true + }, + "eslint-import-resolver-node": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-n": { + "version": "18.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-18.0.1.tgz", + "integrity": "sha512-q3ARhk+eZRc7myR0KHx+R3/GJeOHF+Ir6PK95Pu2tEX8Sl/4BIpmmVLva2kPrjC2gCmn6WHlHm+3yeo6Rxhycw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.5.0", + "enhanced-resolve": "^5.17.1", + "eslint-plugin-es-x": "^7.8.0", + "get-tsconfig": "^4.8.1", + "globals": "^15.11.0", + "globrex": "^0.1.2", + "ignore": "^5.3.2", + "semver": "^7.6.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": ">=8.57.1", + "ts-declaration-location": "^1.0.6", + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "ts-declaration-location": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-promise": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-7.3.0.tgz", + "integrity": "sha512-6uGiOR0INuujr6PEQmeSSP7GbIMJ/ebEXXiEzb/nOj68LknH5Pxzb/AbZivmr6VE6TkTE8rTjRK9zhKpK6HsRA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-security": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-security/-/eslint-plugin-security-4.0.0.tgz", + "integrity": "sha512-tfuQT8K/Li1ZxhFzyD8wPIKtlzZxqBcPr9q0jFMQ77wWAbKBVEhaMPVQRTMTvCMUDhwBe5vPVqQPwAGk/ASfxQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-regex": "^2.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-unicorn": { + "version": "64.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-64.0.0.tgz", + "integrity": "sha512-rNZwalHh8i0UfPlhNwg5BTUO1CMdKNmjqe+TgzOTZnpKoi8VBgsW7u9qCHIdpxEzZ1uwrJrPF0uRb7l//K38gA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "@eslint-community/eslint-utils": "^4.9.1", + "change-case": "^5.4.4", + "ci-info": "^4.4.0", + "clean-regexp": "^1.0.0", + "core-js-compat": "^3.49.0", + "find-up-simple": "^1.0.1", + "globals": "^17.4.0", + "indent-string": "^5.0.0", + "is-builtin-module": "^5.0.0", + "jsesc": "^3.1.0", + "pluralize": "^8.0.0", + "regexp-tree": "^0.1.27", + "regjsparser": "^0.13.0", + "semver": "^7.7.4", + "strip-indent": "^4.1.1" + }, + "engines": { + "node": "^20.10.0 || >=21.0.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/eslint-plugin-unicorn?sponsor=1" + }, + "peerDependencies": { + "eslint": ">=9.38.0" + } + }, + "node_modules/eslint-plugin-unicorn/node_modules/globals": { + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/fast-copy": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.3.tgz", + "integrity": "sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.4.0.tgz", + "integrity": "sha512-ibRCQ0GZKJIQ+P3Et1h0LhPgp3PMTYk0MH8O+kW3lNYsvmaQww5Nn3f1jf73Q0jR1Yz3a1CDP4/NZD3vOajWJQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-json-stringify/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/fast-json-stringify/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/fast-jwt": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/fast-jwt/-/fast-jwt-5.0.6.tgz", + "integrity": "sha512-LPE7OCGUl11q3ZgW681cEU2d0d2JZ37hhJAmetCgNyW8waVaJVZXhyFF6U2so1Iim58Yc7pfxJe2P7MNetQH2g==", + "license": "Apache-2.0", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "asn1.js": "^5.4.1", + "ecdsa-sig-formatter": "^1.0.11", + "mnemonist": "^0.40.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastfall": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/fastfall/-/fastfall-1.5.1.tgz", + "integrity": "sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q==", + "license": "MIT", + "dependencies": { + "reusify": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fastify": { + "version": "5.8.5", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.5.tgz", + "integrity": "sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^6.0.0", + "find-my-way": "^9.0.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastparallel": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/fastparallel/-/fastparallel-2.4.1.tgz", + "integrity": "sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4", + "xtend": "^4.0.2" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fastseries": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/fastseries/-/fastseries-1.7.2.tgz", + "integrity": "sha512-dTPFrPGS8SNSzAt7u/CbMKCJ3s01N04s4JFbORHcmyvVfVKmbhMD1VtRbh5enGHxkaQDqWyLefiKOGGmohGDDQ==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.0", + "xtend": "^4.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-my-way": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz", + "integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-up-simple": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz", + "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/giget": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "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", + "license": "BlueOak-1.0.0", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globrex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", + "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", + "dev": true, + "license": "MIT" + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/helmet": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz", + "integrity": "sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ioredis": { + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz", + "integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.10.0", + "cluster-key-slot": "1.1.1", + "debug": "4.4.3", + "denque": "2.1.0", + "redis-errors": "1.2.0", + "redis-parser": "3.0.0", + "standard-as-callback": "2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ipaddr.js": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", + "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/is-builtin-module": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-5.0.0.tgz", + "integrity": "sha512-f4RqJKBUe5rQkJ2eJEJBXSticB3hGbN9j0yxxMQFqIW89Jp9WYFtzfTcRlstDKVUTRzSOTLKRfO9vIztenwtxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "builtin-modules": "^5.0.0" + }, + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^9.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "devOptional": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mnemonist": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.0.tgz", + "integrity": "sha512-kdd8AFNig2AD5Rkih7EPCXhu/iMvwevQFX/uEiGhZyPZi7fHqOoF4V4kHLpCfysxXMgQ4B52kdPMCwARshKvEg==", + "license": "MIT", + "dependencies": { + "obliterator": "^2.0.4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/nypm": { + "version": "0.6.6", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.6.tgz", + "integrity": "sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.2.2", + "pathe": "^2.0.3", + "tinyexec": "^1.1.1" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/nypm/node_modules/citty": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", + "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/obliterator": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", + "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==", + "license": "MIT" + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-pretty": { + "version": "13.1.3", + "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz", + "integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^4.0.0", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pump": "^3.0.0", + "secure-json-parse": "^4.0.0", + "sonic-boom": "^4.0.1", + "strip-json-comments": "^5.0.2" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prisma": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz", + "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/config": "6.19.3", + "@prisma/engines": "6.19.3" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regexp-tree": { + "version": "0.1.27", + "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", + "integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==", + "dev": true, + "license": "MIT", + "bin": { + "regexp-tree": "bin/regexp-tree" + } + }, + "node_modules/regjsparser": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.1.tgz", + "integrity": "sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", + "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.61.1", + "@rollup/rollup-android-arm64": "4.61.1", + "@rollup/rollup-darwin-arm64": "4.61.1", + "@rollup/rollup-darwin-x64": "4.61.1", + "@rollup/rollup-freebsd-arm64": "4.61.1", + "@rollup/rollup-freebsd-x64": "4.61.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", + "@rollup/rollup-linux-arm-musleabihf": "4.61.1", + "@rollup/rollup-linux-arm64-gnu": "4.61.1", + "@rollup/rollup-linux-arm64-musl": "4.61.1", + "@rollup/rollup-linux-loong64-gnu": "4.61.1", + "@rollup/rollup-linux-loong64-musl": "4.61.1", + "@rollup/rollup-linux-ppc64-gnu": "4.61.1", + "@rollup/rollup-linux-ppc64-musl": "4.61.1", + "@rollup/rollup-linux-riscv64-gnu": "4.61.1", + "@rollup/rollup-linux-riscv64-musl": "4.61.1", + "@rollup/rollup-linux-s390x-gnu": "4.61.1", + "@rollup/rollup-linux-x64-gnu": "4.61.1", + "@rollup/rollup-linux-x64-musl": "4.61.1", + "@rollup/rollup-openbsd-x64": "4.61.1", + "@rollup/rollup-openharmony-arm64": "4.61.1", + "@rollup/rollup-win32-arm64-msvc": "4.61.1", + "@rollup/rollup-win32-ia32-msvc": "4.61.1", + "@rollup/rollup-win32-x64-gnu": "4.61.1", + "@rollup/rollup-win32-x64-msvc": "4.61.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-2.1.1.tgz", + "integrity": "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "regexp-tree": "~0.1.1" + } + }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/stable-hash-x": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz", + "integrity": "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/steed": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/steed/-/steed-1.1.3.tgz", + "integrity": "sha512-EUkci0FAUiE4IvGTSKcDJIQ/eRUP2JJb56+fvZ4sdnguLTqIdKjSxUe138poW8mkvKWXW2sFPrgTsxqoISnmoA==", + "license": "MIT", + "dependencies": { + "fastfall": "^1.5.0", + "fastparallel": "^2.2.0", + "fastq": "^1.3.0", + "fastseries": "^1.7.0", + "reusify": "^1.0.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-indent": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.1.1.tgz", + "integrity": "sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/toad-cache": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.1.tgz", + "integrity": "sha512-5DXWzE4Vz7xNHsv+xQ+MGfJYyC78Aok3tEr0MNwHoRf7vZnga1mQXZ4/Nsodld4VR6Wd+VhfmqnNrsRJyYPfrQ==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.1.tgz", + "integrity": "sha512-6m5hkkRAp8lKvhVpcprAIn5KkehQEh+47oHH2VGnExEh7dhNxXlg6GPAOIu6TxbVQxhebrJDvjl3020ooiWCMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.60.1", + "@typescript-eslint/parser": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1", + "@typescript-eslint/utils": "8.60.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unrs-resolver": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", + "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.4" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.12.2", + "@unrs/resolver-binding-android-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-x64": "1.12.2", + "@unrs/resolver-binding-freebsd-x64": "1.12.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", + "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-musl": "1.12.2", + "@unrs/resolver-binding-openharmony-arm64": "1.12.2", + "@unrs/resolver-binding-wasm32-wasi": "1.12.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/apps/backend/package.json b/apps/backend/package.json index 8bc19bf8..d71b0777 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -15,10 +15,11 @@ "db:deploy": "prisma migrate deploy", "db:seed": "tsx prisma/seed.ts", "db:studio": "prisma studio", + "postinstall": "prisma generate", "typecheck": "tsc --noEmit" }, "dependencies": { - "@devcard/shared": "workspace:*", + "@devcard/shared": "file:../../packages/shared", "@fastify/cookie": "^11.0.0", "@fastify/cors": "^10.0.0", "@fastify/helmet": "^12.0.0", diff --git a/apps/backend/prisma/migrations/20260312125106_init/migration.sql b/apps/backend/prisma/migrations/20260312125106_init/migration.sql deleted file mode 100644 index 92f21903..00000000 --- a/apps/backend/prisma/migrations/20260312125106_init/migration.sql +++ /dev/null @@ -1,99 +0,0 @@ --- CreateTable -CREATE TABLE "users" ( - "id" TEXT NOT NULL, - "email" TEXT NOT NULL, - "username" TEXT NOT NULL, - "display_name" TEXT NOT NULL, - "bio" TEXT, - "pronouns" TEXT, - "role" TEXT, - "company" TEXT, - "avatar_url" TEXT, - "accent_color" TEXT NOT NULL DEFAULT '#6366f1', - "provider" TEXT NOT NULL, - "provider_id" TEXT NOT NULL, - "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "users_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "platform_links" ( - "id" TEXT NOT NULL, - "user_id" TEXT NOT NULL, - "platform" TEXT NOT NULL, - "username" TEXT NOT NULL, - "url" TEXT NOT NULL, - "display_order" INTEGER NOT NULL DEFAULT 0, - "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "platform_links_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "cards" ( - "id" TEXT NOT NULL, - "user_id" TEXT NOT NULL, - "title" TEXT NOT NULL, - "is_default" BOOLEAN NOT NULL DEFAULT false, - "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "cards_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "card_links" ( - "id" TEXT NOT NULL, - "card_id" TEXT NOT NULL, - "platform_link_id" TEXT NOT NULL, - "display_order" INTEGER NOT NULL DEFAULT 0, - - CONSTRAINT "card_links_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "oauth_tokens" ( - "id" TEXT NOT NULL, - "user_id" TEXT NOT NULL, - "platform" TEXT NOT NULL, - "access_token" TEXT NOT NULL, - "refresh_token" TEXT, - "scopes" TEXT NOT NULL, - "expires_at" TIMESTAMP(3), - "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "oauth_tokens_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); - --- CreateIndex -CREATE UNIQUE INDEX "users_username_key" ON "users"("username"); - --- CreateIndex -CREATE UNIQUE INDEX "users_provider_provider_id_key" ON "users"("provider", "provider_id"); - --- CreateIndex -CREATE UNIQUE INDEX "card_links_card_id_platform_link_id_key" ON "card_links"("card_id", "platform_link_id"); - --- CreateIndex -CREATE UNIQUE INDEX "oauth_tokens_user_id_platform_key" ON "oauth_tokens"("user_id", "platform"); - --- AddForeignKey -ALTER TABLE "platform_links" ADD CONSTRAINT "platform_links_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "cards" ADD CONSTRAINT "cards_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "card_links" ADD CONSTRAINT "card_links_card_id_fkey" FOREIGN KEY ("card_id") REFERENCES "cards"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "card_links" ADD CONSTRAINT "card_links_platform_link_id_fkey" FOREIGN KEY ("platform_link_id") REFERENCES "platform_links"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "oauth_tokens" ADD CONSTRAINT "oauth_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/backend/prisma/migrations/20260312162249_analytics_models/migration.sql b/apps/backend/prisma/migrations/20260312162249_analytics_models/migration.sql deleted file mode 100644 index f185bdd8..00000000 --- a/apps/backend/prisma/migrations/20260312162249_analytics_models/migration.sql +++ /dev/null @@ -1,38 +0,0 @@ --- CreateTable -CREATE TABLE "card_views" ( - "id" TEXT NOT NULL, - "card_id" TEXT, - "owner_id" TEXT NOT NULL, - "viewer_id" TEXT, - "viewer_ip" TEXT, - "viewer_agent" TEXT, - "source" TEXT NOT NULL DEFAULT 'qr', - "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "card_views_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "follow_logs" ( - "id" TEXT NOT NULL, - "follower_id" TEXT NOT NULL, - "target_username" TEXT NOT NULL, - "platform" TEXT NOT NULL, - "status" TEXT NOT NULL DEFAULT 'success', - "layer" TEXT NOT NULL, - "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "follow_logs_pkey" PRIMARY KEY ("id") -); - --- AddForeignKey -ALTER TABLE "card_views" ADD CONSTRAINT "card_views_card_id_fkey" FOREIGN KEY ("card_id") REFERENCES "cards"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "card_views" ADD CONSTRAINT "card_views_owner_id_fkey" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "card_views" ADD CONSTRAINT "card_views_viewer_id_fkey" FOREIGN KEY ("viewer_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "follow_logs" ADD CONSTRAINT "follow_logs_follower_id_fkey" FOREIGN KEY ("follower_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 28458021..2184aeaf 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -6,7 +6,12 @@ datasource db { url = env("DATABASE_URL") } - +enum Role{ + SUPERADMIN + ADMIN + USER + +} model User { id String @id @default(uuid()) email String @unique @@ -15,28 +20,64 @@ model User { bio String? pronouns String? role String? + authRole Role @default(USER) company String? avatarUrl String? @map("avatar_url") accentColor String @default("#6366f1") @map("accent_color") - provider String - providerId String @map("provider_id") + emailVerified Boolean @default(false) @map("email_verified") + phoneNumber String? @unique @map("phone_number") + lastSignInAt DateTime? @map("last_sign_in_at") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") + isActive Boolean @default(false) + identities UserIdentity[] + refreshTokens RefreshToken[] platformLinks PlatformLink[] cards Card[] oauthTokens OAuthToken[] ownedViews CardView[] @relation("cardOwner") viewedCards CardView[] @relation("cardViewer") followLogs FollowLog[] - organizer Event[] - attendedEvents EventAttendee[] + organizer Event[] + attendedEvents EventAttendee[] + ownedTeams Team[] @relation("TeamOwner") + teamMemberships TeamMember[] @relation("TeamMember") + + @@map("users") +} + +model UserIdentity { + id String @id @default(uuid()) + userId String @map("user_id") + provider String // "google.com" | "apple.com" | "firebase" | "phone" + providerId String @map("provider_id") // Google sub / Apple sub / Firebase UID + createdAt DateTime @default(now()) @map("created_at") - ownedTeams Team[] @relation("TeamOwner") - teamMemberships TeamMember[] @relation("TeamMember") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([provider, providerId]) - @@map("users") + @@index([userId]) + @@map("user_identities") +} + + +model RefreshToken { + id String @id @default(uuid()) + userId String @map("user_id") + tokenHash String @unique @map("token_hash") //SHA-256 hash + family String // token rotation + expiresAt DateTime @map("expires_at") + revokedAt DateTime? @map("revoked_at") // null = still valid + createdAt DateTime @default(now()) @map("created_at") + userAgent String? @map("user_agent") + ip String? //hash + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@index([family]) + @@map("refresh_tokens") } model PlatformLink { @@ -54,19 +95,40 @@ model PlatformLink { @@map("platform_links") } +enum CardVisibility { + PUBLIC // Anyone can view the card + UNLISTED // Anyone with the link can view, but not publicly listed + PRIVATE // Only the card owner can view +} + model Card { id String @id @default(uuid()) userId String @map("user_id") + title String + description String? + + slug String @unique + + visibility CardVisibility @default(PUBLIC) + + qrEnabled Boolean @default(true) + + viewCount Int @default(0) + isDefault Boolean @default(false) @map("is_default") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) + cardLinks CardLink[] views CardView[] + @@map("cards") + @@index([userId]) + @@index([viewCount]) } model CardLink { @@ -104,7 +166,7 @@ model CardView { cardId String? @map("card_id") // null = default profile view ownerId String @map("owner_id") // card/profile owner viewerId String? @map("viewer_id") // null = anonymous web viewer - viewerIp String? @map("viewer_ip") + viewerIp String? @map("viewer_ip") //hashed viewerAgent String? @map("viewer_agent") source String @default("qr") // "qr" | "link" | "web" | "app" createdAt DateTime @default(now()) @map("created_at") @@ -114,6 +176,8 @@ model CardView { viewer User? @relation("cardViewer", fields: [viewerId], references: [id], onDelete: SetNull) @@map("card_views") + @@index([cardId]) + @@index([ownerId]) } model FollowLog { diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index f19345d8..44cec8d1 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -1,114 +1,92 @@ -import { PrismaClient } from '@prisma/client'; +import { PrismaClient, Role, CardVisibility, TeamRole } from '@prisma/client'; const prisma = new PrismaClient(); async function main() { - console.log('🌱 Seeding DevCard database...'); + console.log('Seeding DevCard database...'); - // Create test user - const testUser = await prisma.user.upsert({ + // --------------------------------------------------------------------------- + // Reset existing demo data (idempotent re-runs). + // Order matters: Team.owner uses onDelete: Restrict, so teams must be removed + // before their owning user. Most other relations cascade from the user. + // --------------------------------------------------------------------------- + const existing = await prisma.user.findUnique({ where: { username: 'devcard-demo' }, - update: {}, - create: { + select: { id: true }, + }); + + if (existing) { + await prisma.teamMember.deleteMany({ where: { userId: existing.id } }); + await prisma.team.deleteMany({ where: { ownerId: existing.id } }); + await prisma.eventAttendee.deleteMany({ where: { userId: existing.id } }); + await prisma.event.deleteMany({ where: { organizerId: existing.id } }); + await prisma.user.delete({ where: { id: existing.id } }); + } + + // --------------------------------------------------------------------------- + // User + // --------------------------------------------------------------------------- + const testUser = await prisma.user.create({ + data: { email: 'demo@devcard.dev', username: 'devcard-demo', displayName: 'Alex Chen', bio: 'Full-stack developer • Open source enthusiast • Builder of things', pronouns: 'they/them', role: 'Senior Software Engineer', + authRole: Role.USER, company: 'OpenSource Inc.', avatarUrl: null, accentColor: '#6366f1', - provider: 'github', - providerId: 'demo-12345', + emailVerified: true, + phoneNumber: '+10000000000', + isActive: true, + identities: { + create: { + provider: 'github', + providerId: 'demo-12345', + }, + }, }, }); - console.log(` ✅ Created user: ${testUser.displayName} (@${testUser.username})`); - - // Create platform links - const links = await Promise.all([ - prisma.platformLink.create({ - data: { - userId: testUser.id, - platform: 'github', - username: 'alexchen', - url: 'https://github.com/alexchen', - displayOrder: 0, - }, - }), - prisma.platformLink.create({ - data: { - userId: testUser.id, - platform: 'linkedin', - username: 'alexchen-dev', - url: 'https://www.linkedin.com/in/alexchen-dev', - displayOrder: 1, - }, - }), - prisma.platformLink.create({ - data: { - userId: testUser.id, - platform: 'twitter', - username: 'alexchendev', - url: 'https://x.com/alexchendev', - displayOrder: 2, - }, - }), - prisma.platformLink.create({ - data: { - userId: testUser.id, - platform: 'devfolio', - username: 'alexchen', - url: 'https://devfolio.co/@alexchen', - displayOrder: 3, - }, - }), - prisma.platformLink.create({ - data: { - userId: testUser.id, - platform: 'portfolio', - username: 'https://alexchen.dev', - url: 'https://alexchen.dev', - displayOrder: 4, - }, - }), - prisma.platformLink.create({ - data: { - userId: testUser.id, - platform: 'leetcode', - username: 'alexchen', - url: 'https://leetcode.com/u/alexchen', - displayOrder: 5, - }, - }), - prisma.platformLink.create({ - data: { - userId: testUser.id, - platform: 'discord', - username: 'alexchen#4242', - url: '', - displayOrder: 6, - }, - }), - prisma.platformLink.create({ - data: { - userId: testUser.id, - platform: 'email', - username: 'alex@devcard.dev', - url: 'mailto:alex@devcard.dev', - displayOrder: 7, - }, - }), - ]); + console.log(` Created user: ${testUser.displayName} (@${testUser.username})`); + + // --------------------------------------------------------------------------- + // Platform links + // --------------------------------------------------------------------------- + const linkData = [ + { platform: 'github', username: 'alexchen', url: 'https://github.com/alexchen' }, + { platform: 'linkedin', username: 'alexchen-dev', url: 'https://www.linkedin.com/in/alexchen-dev' }, + { platform: 'twitter', username: 'alexchendev', url: 'https://x.com/alexchendev' }, + { platform: 'devfolio', username: 'alexchen', url: 'https://devfolio.co/@alexchen' }, + { platform: 'portfolio', username: 'https://alexchen.dev', url: 'https://alexchen.dev' }, + { platform: 'leetcode', username: 'alexchen', url: 'https://leetcode.com/u/alexchen' }, + { platform: 'discord', username: 'alexchen#4242', url: '' }, + { platform: 'email', username: 'alex@devcard.dev', url: 'mailto:alex@devcard.dev' }, + ]; + + const links = await Promise.all( + linkData.map((data, displayOrder) => + prisma.platformLink.create({ + data: { userId: testUser.id, displayOrder, ...data }, + }) + ) + ); - console.log(` ✅ Created ${links.length} platform links`); + console.log(` Created ${links.length} platform links`); - // Create context cards + // --------------------------------------------------------------------------- + // Cards + // --------------------------------------------------------------------------- const professionalCard = await prisma.card.create({ data: { userId: testUser.id, title: 'Professional', + description: 'My professional links for work and networking.', + slug: 'devcard-demo-professional', + visibility: CardVisibility.PUBLIC, + qrEnabled: true, isDefault: true, cardLinks: { create: [ @@ -125,6 +103,10 @@ async function main() { data: { userId: testUser.id, title: 'Hackathon', + description: 'Find me at hackathons and dev events.', + slug: 'devcard-demo-hackathon', + visibility: CardVisibility.UNLISTED, + qrEnabled: true, isDefault: false, cardLinks: { create: [ @@ -137,14 +119,61 @@ async function main() { }, }); - console.log(` ✅ Created cards: "${professionalCard.title}", "${hackathonCard.title}"`); - console.log('\n🎉 Seed complete! Try: GET /api/u/devcard-demo'); + console.log(` Created cards: "${professionalCard.title}", "${hackathonCard.title}"`); + + // --------------------------------------------------------------------------- + // Event + attendee + // --------------------------------------------------------------------------- + const event = await prisma.event.create({ + data: { + name: 'DevCard Launch Hackathon', + slug: 'devcard-launch-hackathon', + location: 'San Francisco, CA', + description: 'A weekend hackathon to celebrate the DevCard launch.', + organizerId: testUser.id, + startDate: new Date('2026-07-01T09:00:00Z'), + endDate: new Date('2026-07-03T18:00:00Z'), + isPublic: true, + attendees: { + create: { + userId: testUser.id, + joinedAt: new Date('2026-06-15T12:00:00Z'), + }, + }, + }, + }); + + console.log(` Created event: "${event.name}"`); + + // --------------------------------------------------------------------------- + // Team + membership + // --------------------------------------------------------------------------- + const team = await prisma.team.create({ + data: { + name: 'OpenSource Inc.', + slug: 'opensource-inc', + description: 'The team behind DevCard.', + avatarUrl: null, + ownerId: testUser.id, + members: { + create: { + userId: testUser.id, + role: TeamRole.OWNER, + joinedAt: new Date('2026-06-10T08:00:00Z'), + }, + }, + }, + }); + + console.log(` Created team: "${team.name}"`); + + console.log('\nSeed complete! Try: GET /api/u/devcard-demo'); } main() .catch((error) => { - console.error('❌ Seed failed:', error); - process.exit(1); + console.error('Seed failed:', error); + return; }) .finally(async () => { await prisma.$disconnect(); diff --git a/apps/backend/src/__tests__/app.test.ts b/apps/backend/src/__tests__/app.test.ts index 648d98a6..b238961b 100644 --- a/apps/backend/src/__tests__/app.test.ts +++ b/apps/backend/src/__tests__/app.test.ts @@ -1,8 +1,13 @@ -process.env.NODE_ENV = 'test'; +import { describe, it, expect, vi } from 'vitest'; -import { describe, it, expect } from 'vitest'; import { buildApp } from '../app'; +process.env.NODE_ENV = 'test'; +// validateEnv() runs inside buildApp() and exits if these are absent. +// Provide safe test-only fallbacks so CI doesn't need real secrets here. +process.env.JWT_SECRET ??= 'test-jwt-secret-not-for-production-xxxxxxxxxxxxxxxxxxxxxxx'; +process.env.ENCRYPTION_KEY ??= 'a'.repeat(64); + describe('GET /health', () => { it('should return status ok', async () => { const app = await buildApp(); @@ -15,6 +20,22 @@ describe('GET /health', () => { expect(res.statusCode).toBe(200); expect(JSON.parse(res.body)).toEqual({ status: 'ok' }); + await app.close(); + }); +}); + +describe('request logging hook', () => { + it('logs method and url for each request', async () => { + const app = await buildApp(); + const spy = vi.spyOn(app.log, 'info'); + + await app.inject({ method: 'GET', url: '/health' }); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ method: 'GET', url: '/health' }), + 'incoming request', + ); + await app.close(); }); }); \ No newline at end of file diff --git a/apps/backend/src/__tests__/auth-callback.test.ts b/apps/backend/src/__tests__/auth-callback.test.ts new file mode 100644 index 00000000..ed6bb4cb --- /dev/null +++ b/apps/backend/src/__tests__/auth-callback.test.ts @@ -0,0 +1,203 @@ +import cookiePlugin from '@fastify/cookie'; +import jwtPlugin from '@fastify/jwt'; +import Fastify, { type FastifyInstance } from 'fastify'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +import { authRoutes } from '../routes/auth.js'; + +async function buildTestApp(): Promise { + const app = Fastify({ logger: false }); + + await app.register(cookiePlugin as any); + await app.register(jwtPlugin as any, { + secret: 'test-secret-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + cookie: { cookieName: 'access_Token', signed: false }, + }); + + app.decorate('prisma', { + user: { findUnique: vi.fn(), create: vi.fn(), update: vi.fn() }, + userIdentity: { findUnique: vi.fn(), create: vi.fn() }, + refreshToken: { create: vi.fn() }, + } as any); + + app.decorate('redis', { + set: vi.fn(), + getdel: vi.fn(), + } as any); + + app.decorate('authenticate', async () => {}); + + await app.register(authRoutes, { prefix: '/auth' }); + await app.ready(); + return app; +} + +function cookieCleared(res: any): boolean { + const raw = res.headers['set-cookie'] as string | string[] | undefined; + const cookies = Array.isArray(raw) ? raw : raw ? [raw] : []; + return cookies.some((c) => c.startsWith('oauth_state=;') || c.includes('oauth_state=; ')); +} + +describe('GET /auth/github/callback — Zod validation', () => { + let app: FastifyInstance; + + beforeEach(async () => { + vi.clearAllMocks(); + app = await buildTestApp(); + }); + + afterEach(async () => { + await app.close(); + }); + + it('400 — missing code rejects with validation error', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/github/callback?state=somestate', + headers: { Cookie: 'oauth_state=somestate' }, + }); + + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe('Invalid callback parameters'); + expect(cookieCleared(res)).toBe(true); + }); + + it('400 — empty code rejects with validation error', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/github/callback?code=&state=somestate', + headers: { Cookie: 'oauth_state=somestate' }, + }); + + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe('Invalid callback parameters'); + expect(cookieCleared(res)).toBe(true); + }); + + it('400 — missing state rejects with validation error', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/github/callback?code=validcode', + }); + + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe('Invalid callback parameters'); + expect(cookieCleared(res)).toBe(true); + }); + + it('400 — empty state rejects with validation error', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/github/callback?code=validcode&state=', + }); + + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe('Invalid callback parameters'); + expect(cookieCleared(res)).toBe(true); + }); + + it('400 — valid code and state but no cookie rejects with state error', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/github/callback?code=validcode&state=somestate', + }); + + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe('Invalid or missing OAuth state — possible CSRF attack'); + expect(cookieCleared(res)).toBe(true); + }); + + it('400 — valid code and state but mismatched cookie rejects with state error', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/github/callback?code=validcode&state=somestate', + headers: { Cookie: 'oauth_state=differentstate' }, + }); + + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe('Invalid or missing OAuth state — possible CSRF attack'); + expect(cookieCleared(res)).toBe(true); + }); +}); + +describe('GET /auth/google/callback — Zod validation', () => { + let app: FastifyInstance; + + beforeEach(async () => { + vi.clearAllMocks(); + app = await buildTestApp(); + }); + + afterEach(async () => { + await app.close(); + }); + + it('400 — missing code rejects with validation error', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/google/callback?state=somestate', + headers: { Cookie: 'oauth_state=somestate' }, + }); + + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe('Invalid callback parameters'); + expect(cookieCleared(res)).toBe(true); + }); + + it('400 — empty code rejects with validation error', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/google/callback?code=&state=somestate', + headers: { Cookie: 'oauth_state=somestate' }, + }); + + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe('Invalid callback parameters'); + expect(cookieCleared(res)).toBe(true); + }); + + it('400 — missing state rejects with validation error', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/google/callback?code=validcode', + }); + + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe('Invalid callback parameters'); + expect(cookieCleared(res)).toBe(true); + }); + + it('400 — empty state rejects with validation error', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/google/callback?code=validcode&state=', + }); + + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe('Invalid callback parameters'); + expect(cookieCleared(res)).toBe(true); + }); + + it('400 — valid code and state but no cookie rejects with state error', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/google/callback?code=validcode&state=somestate', + }); + + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe('Invalid or missing OAuth state — possible CSRF attack'); + expect(cookieCleared(res)).toBe(true); + }); + + it('400 — valid code and state but mismatched cookie rejects with state error', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/google/callback?code=validcode&state=somestate', + headers: { Cookie: 'oauth_state=differentstate' }, + }); + + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe('Invalid or missing OAuth state — possible CSRF attack'); + expect(cookieCleared(res)).toBe(true); + }); +}); diff --git a/apps/backend/src/__tests__/auth.test.ts b/apps/backend/src/__tests__/auth.test.ts new file mode 100644 index 00000000..8814af54 --- /dev/null +++ b/apps/backend/src/__tests__/auth.test.ts @@ -0,0 +1,210 @@ +import Fastify from 'fastify'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +import { authRoutes } from '../routes/auth'; + +import type { JWT } from '@fastify/jwt'; +import type { PrismaClient } from '@prisma/client'; +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import type { Redis } from 'ioredis'; + + +const MOCK_CLIENT_ID = 'mock-github-client-id'; +const MOCK_GOOGLE_CLIENT_ID = 'mock-google-client-id'; +const MOCK_BACKEND_URL = 'http://localhost:3000'; + + +async function buildApp(): Promise { + const app = Fastify({ logger: false }); + + await app.register(import('@fastify/cookie')); + + //as not testing this here + app.decorate('authenticate', async (_request: FastifyRequest, reply: FastifyReply) => { + reply.status(401).send({ error: 'Unauthorized' }); + }); + + app.decorate('jwt', { + sign: vi.fn().mockReturnValue('mock-token'), + decode: vi.fn(), + verify: vi.fn(), + } as unknown as JWT); + + app.decorate('prisma', { + user: { findUnique: vi.fn(), create: vi.fn(), update: vi.fn() }, + userIdentity: { findUnique: vi.fn(), create: vi.fn() }, + refreshToken: { findUnique: vi.fn(), create: vi.fn(), update: vi.fn(), updateMany: vi.fn() }, + } as unknown as PrismaClient); + + app.decorate('redis', { + set: vi.fn(), + get: vi.fn(), + getdel: vi.fn(), + } as unknown as Redis); + + await app.register(authRoutes, { prefix: '/auth' }); + await app.ready(); + return app; +} + +describe('Auth API — OAuth initiation', () => { + let app: FastifyInstance; + + beforeEach(async () => { + vi.clearAllMocks(); + vi.stubEnv('GITHUB_CLIENT_ID', MOCK_CLIENT_ID); + vi.stubEnv('GOOGLE_CLIENT_ID', MOCK_GOOGLE_CLIENT_ID); + vi.stubEnv('BACKEND_URL', MOCK_BACKEND_URL); + vi.stubEnv('NODE_ENV', 'test'); + app = await buildApp(); //fresh app instance before and after each instance + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await app.close(); //fresh app instance before and after each instance + }); + + // /auth/github + describe('GET /auth/github — OAuth initiation', () => { + it('302 — redirects to GitHub with valid query params', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/github', + }); + + expect(res.statusCode).toBe(302); + expect(res.headers.location).toContain('github.com/login/oauth/authorize'); + }); + + it('302 — sets oauth_state cookie on redirect', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/github', + }); + + expect(res.statusCode).toBe(302); + expect(res.headers['set-cookie']).toBeDefined(); + expect(res.headers['set-cookie']).toMatch(/oauth_state=/); + }); + + it('302 — accepts valid state param', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/github?state=some-client-state', + }); + + expect(res.statusCode).toBe(302); + }); + + it('302 — accepts valid mobile_redirect_uri', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/github?mobile_redirect_uri=devcard://callback', + }); + + expect(res.statusCode).toBe(302); + }); + + it('400 — rejects invalid mobile_redirect_uri', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/github?mobile_redirect_uri=https://evil.com/callback', + }); + + expect(res.statusCode).toBe(400); + expect(res.json()).toMatchObject({ error: expect.any(String) }); + }); + + it('400 — rejects mobile_redirect_uri that is not devcard:// scheme', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/github?mobile_redirect_uri=http://localhost/callback', + }); + + expect(res.statusCode).toBe(400); + }); + + it('400 — returns 400 when GITHUB_CLIENT_ID is missing', async () => { + vi.stubEnv('GITHUB_CLIENT_ID', ''); + + const res = await app.inject({ + method: 'GET', + url: '/auth/github', + }); + + expect(res.statusCode).toBe(400); + }); + }); + + // /auth/google + describe('GET /auth/google — OAuth initiation', () => { + it('302 — redirects to Google with valid query params', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/google', + }); + + expect(res.statusCode).toBe(302); + expect(res.headers.location).toContain('accounts.google.com/o/oauth2/v2/auth'); + }); + + it('302 — sets oauth_state cookie on redirect', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/google', + }); + + expect(res.statusCode).toBe(302); + expect(res.headers['set-cookie']).toBeDefined(); + expect(res.headers['set-cookie']).toMatch(/oauth_state=/); + }); + + it('302 — accepts valid state param', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/google?state=some-client-state', + }); + + expect(res.statusCode).toBe(302); + }); + + it('302 — accepts valid mobile_redirect_uri', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/google?mobile_redirect_uri=devcard://callback', + }); + + expect(res.statusCode).toBe(302); + }); + + it('400 — rejects invalid mobile_redirect_uri', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/google?mobile_redirect_uri=https://evil.com/callback', + }); + + expect(res.statusCode).toBe(400); + expect(res.json()).toMatchObject({ error: expect.any(String) }); + }); + + it('400 — rejects mobile_redirect_uri that is not devcard:// scheme', async () => { + const res = await app.inject({ + method: 'GET', + url: '/auth/google?mobile_redirect_uri=http://localhost/callback', + }); + + expect(res.statusCode).toBe(400); + }); + + it('400 — returns 400 when GOOGLE_CLIENT_ID is missing', async () => { + vi.stubEnv('GOOGLE_CLIENT_ID', ''); + + const res = await app.inject({ + method: 'GET', + url: '/auth/google', + }); + + expect(res.statusCode).toBe(400); + }); + }); +}); \ No newline at end of file diff --git a/apps/backend/src/__tests__/cards.test.ts b/apps/backend/src/__tests__/cards.test.ts index 813883e8..a8d78e9c 100644 --- a/apps/backend/src/__tests__/cards.test.ts +++ b/apps/backend/src/__tests__/cards.test.ts @@ -1,10 +1,14 @@ +import { CardVisibility } from '@prisma/client'; +import Fastify, { type FastifyInstance } from 'fastify'; import { describe, it, expect, beforeEach, vi } from 'vitest'; -import Fastify from 'fastify'; + import { cardRoutes } from '../routes/cards.js'; +import type { PrismaClient } from '@prisma/client'; + const USER_ID = 'user-123'; const CARD_ID = 'card-abc'; -// Must be valid UUIDs — createCardSchema and updateCardSchema use z.string().uuid() +// Must be valid UUIDs — the card/link schemas use z.string().uuid() const OWNED_LINK_ID = '11111111-1111-1111-1111-111111111111'; const FOREIGN_LINK_ID = '22222222-2222-2222-2222-222222222222'; @@ -12,14 +16,21 @@ const mockCard = { id: CARD_ID, userId: USER_ID, title: 'My Card', + slug: 'my-card', + description: null, + visibility: CardVisibility.PUBLIC, + qrEnabled: true, + viewCount: 0, isDefault: true, createdAt: new Date(), updatedAt: new Date(), cardLinks: [], }; -// $transaction executes the callback synchronously against the same mock client, -// mirroring Prisma's interactive-transactions API without a real DB connection. +// $transaction is used in two shapes by the service/routes: +// 1. interactive: $transaction(async (tx) => ...) — runs the callback against the mock client +// 2. sequential: $transaction([p1, p2]) — resolves an array of pre-built promises +// The mock supports both so error/rollback paths can be asserted without a real DB. const mockPrisma = { card: { count: vi.fn(), @@ -32,26 +43,37 @@ const mockPrisma = { delete: vi.fn(), }, cardLink: { - deleteMany: vi.fn(), - createMany: vi.fn(), + findUnique: vi.fn(), + create: vi.fn(), }, platformLink: { findMany: vi.fn(), + findFirst: vi.fn(), + }, + cardView: { + create: vi.fn(), }, $transaction: vi.fn(), }; -// Re-wire $transaction before every test so that it executes the callback -// against the same mock client, preserving existing per-operation mocks. -function wireTransaction() { - mockPrisma.$transaction.mockImplementation( - async (callback: (tx: typeof mockPrisma) => Promise) => callback(mockPrisma), - ); +// Re-wire $transaction before every test so that the interactive form executes the +// callback against the same mock client (preserving per-operation mocks), and the +// sequential array form resolves like Prisma's Promise.all semantics. +function wireTransaction(): void { + mockPrisma.$transaction.mockImplementation(async (arg: unknown) => { + if (typeof arg === 'function') { + return (arg as (tx: typeof mockPrisma) => Promise)(mockPrisma); + } + if (Array.isArray(arg)) { + return Promise.all(arg); + } + return undefined; + }); } -async function buildApp() { +async function buildApp(): Promise { const app = Fastify({ logger: false }); - app.decorate('prisma', mockPrisma); + app.decorate('prisma', mockPrisma as unknown as PrismaClient); app.decorate('authenticate', async (request: any) => { request.user = { id: USER_ID }; }); @@ -64,7 +86,7 @@ async function buildApp() { // POST /api/cards // ───────────────────────────────────────────────────────────────────────────── -describe('POST /api/cards — link ownership validation', () => { +describe('POST /api/cards — create & link ownership validation', () => { beforeEach(() => { vi.clearAllMocks(); wireTransaction(); @@ -103,6 +125,7 @@ describe('POST /api/cards — link ownership validation', () => { it('creates the card when all linkIds are owned by the user', async () => { mockPrisma.platformLink.findMany.mockResolvedValue([{ id: OWNED_LINK_ID }]); + mockPrisma.card.findUnique.mockResolvedValue(null); // slug is unique mockPrisma.card.count.mockResolvedValue(0); mockPrisma.card.create.mockResolvedValue({ ...mockCard, cardLinks: [] }); @@ -118,12 +141,12 @@ describe('POST /api/cards — link ownership validation', () => { where: { id: { in: [OWNED_LINK_ID] }, userId: USER_ID }, select: { id: true }, }); + // Creation runs inside the (serializable) transaction + expect(mockPrisma.$transaction).toHaveBeenCalledOnce(); + expect(mockPrisma.card.create).toHaveBeenCalled(); }); - it('skips the ownership check and creates the card when linkIds is empty', async () => { - mockPrisma.card.count.mockResolvedValue(1); - mockPrisma.card.create.mockResolvedValue({ ...mockCard, isDefault: false, cardLinks: [] }); - + it('returns 400 when linkIds is empty (schema now requires at least one link)', async () => { const app = await buildApp(); const res = await app.inject({ method: 'POST', @@ -131,10 +154,44 @@ describe('POST /api/cards — link ownership validation', () => { payload: { title: 'Empty Card', linkIds: [] }, }); - expect(res.statusCode).toBe(201); + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe('Validation failed'); + // Validation fails before any DB work + expect(mockPrisma.platformLink.findMany).not.toHaveBeenCalled(); + expect(mockPrisma.card.create).not.toHaveBeenCalled(); + }); + + it('returns 400 when duplicate linkIds are supplied', async () => { + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/cards', + payload: { title: 'Dupe Card', linkIds: [OWNED_LINK_ID, OWNED_LINK_ID] }, + }); + + expect(res.statusCode).toBe(400); expect(mockPrisma.platformLink.findMany).not.toHaveBeenCalled(); }); + it('retries and succeeds when the create hits a serialization conflict (P2034)', async () => { + mockPrisma.platformLink.findMany.mockResolvedValue([{ id: OWNED_LINK_ID }]); + mockPrisma.card.findUnique.mockResolvedValue(null); + mockPrisma.card.count.mockResolvedValue(0); + mockPrisma.card.create + .mockRejectedValueOnce(Object.assign(new Error('serialization failure'), { code: 'P2034' })) + .mockResolvedValueOnce({ ...mockCard, cardLinks: [] }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/cards', + payload: { title: 'Test Card', linkIds: [OWNED_LINK_ID] }, + }); + + expect(res.statusCode).toBe(201); + expect(mockPrisma.$transaction).toHaveBeenCalledTimes(2); + }); + it('returns 500 when the ownership query throws unexpectedly', async () => { mockPrisma.platformLink.findMany.mockRejectedValue(new Error('DB connection lost')); @@ -150,8 +207,9 @@ describe('POST /api/cards — link ownership validation', () => { expect(mockPrisma.card.create).not.toHaveBeenCalled(); }); - it('returns 500 when card.count throws and no partial write occurs', async () => { + it('returns 500 when card.count throws inside the transaction', async () => { mockPrisma.platformLink.findMany.mockResolvedValue([{ id: OWNED_LINK_ID }]); + mockPrisma.card.findUnique.mockResolvedValue(null); mockPrisma.card.count.mockRejectedValue(new Error('Query timeout')); const app = await buildApp(); @@ -165,8 +223,9 @@ describe('POST /api/cards — link ownership validation', () => { expect(mockPrisma.card.create).not.toHaveBeenCalled(); }); - it('returns 500 when card.create throws', async () => { + it('returns 500 when card.create throws a non-retryable error', async () => { mockPrisma.platformLink.findMany.mockResolvedValue([{ id: OWNED_LINK_ID }]); + mockPrisma.card.findUnique.mockResolvedValue(null); mockPrisma.card.count.mockResolvedValue(0); mockPrisma.card.create.mockRejectedValue(new Error('FK constraint violation')); @@ -182,131 +241,174 @@ describe('POST /api/cards — link ownership validation', () => { }); // ───────────────────────────────────────────────────────────────────────────── -// PUT /api/cards/:id +// PUT /api/cards/:id/update // ───────────────────────────────────────────────────────────────────────────── -describe('PUT /api/cards/:id — link ownership validation', () => { +describe('PUT /api/cards/:id/update — card metadata', () => { beforeEach(() => { vi.clearAllMocks(); wireTransaction(); }); - it('returns 403 when a supplied linkId belongs to another user', async () => { + it('updates title/description/visibility/qrEnabled for an owned card', async () => { mockPrisma.card.findFirst.mockResolvedValue(mockCard); - mockPrisma.platformLink.findMany.mockResolvedValue([]); + mockPrisma.card.update.mockResolvedValue({ + ...mockCard, + title: 'Renamed', + visibility: CardVisibility.UNLISTED, + qrEnabled: false, + }); const app = await buildApp(); const res = await app.inject({ method: 'PUT', - url: `/api/cards/${CARD_ID}`, - payload: { linkIds: [FOREIGN_LINK_ID] }, + url: `/api/cards/${CARD_ID}/update`, + payload: { title: 'Renamed', visibility: 'UNLISTED', qrEnabled: false }, }); - expect(res.statusCode).toBe(403); - expect(res.json().error).toBe('One or more links do not belong to your account'); - // Existing links must not have been touched - expect(mockPrisma.$transaction).not.toHaveBeenCalled(); - expect(mockPrisma.cardLink.deleteMany).not.toHaveBeenCalled(); - expect(mockPrisma.cardLink.createMany).not.toHaveBeenCalled(); + expect(res.statusCode).toBe(200); + expect(mockPrisma.card.findFirst).toHaveBeenCalledWith({ where: { id: CARD_ID, userId: USER_ID } }); + expect(mockPrisma.card.update).toHaveBeenCalledWith({ + where: { id: CARD_ID }, + data: { title: 'Renamed', description: undefined, visibility: 'UNLISTED', qrEnabled: false }, + }); }); - it('updates links atomically when all supplied linkIds are owned', async () => { + it('returns 404 when the card does not belong to the user', async () => { + mockPrisma.card.findFirst.mockResolvedValue(null); + + const app = await buildApp(); + const res = await app.inject({ + method: 'PUT', + url: `/api/cards/${CARD_ID}/update`, + payload: { title: 'Renamed' }, + }); + + expect(res.statusCode).toBe(404); + expect(mockPrisma.card.update).not.toHaveBeenCalled(); + }); + + it('returns 400 when the body is empty (schema requires at least one field)', async () => { + const app = await buildApp(); + const res = await app.inject({ + method: 'PUT', + url: `/api/cards/${CARD_ID}/update`, + payload: {}, + }); + + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe('Validation failed'); + expect(mockPrisma.card.findFirst).not.toHaveBeenCalled(); + }); + + it('returns 500 when card.update throws', async () => { mockPrisma.card.findFirst.mockResolvedValue(mockCard); - mockPrisma.platformLink.findMany.mockResolvedValue([{ id: OWNED_LINK_ID }]); - mockPrisma.cardLink.deleteMany.mockResolvedValue({ count: 0 }); - mockPrisma.cardLink.createMany.mockResolvedValue({ count: 1 }); - mockPrisma.card.findUnique.mockResolvedValue({ ...mockCard, cardLinks: [] }); + mockPrisma.card.update.mockRejectedValue(new Error('DB write failure')); + + const app = await buildApp(); + const res = await app.inject({ + method: 'PUT', + url: `/api/cards/${CARD_ID}/update`, + payload: { title: 'Renamed' }, + }); + + expect(res.statusCode).toBe(500); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// PUT /api/cards/:id/platform-link +// ───────────────────────────────────────────────────────────────────────────── + +describe('PUT /api/cards/:id/platform-link', () => { + beforeEach(() => { + vi.clearAllMocks(); + wireTransaction(); + }); + + it('returns 200 when a new owned platform link is added', async () => { + mockPrisma.card.findFirst.mockResolvedValue(mockCard); + mockPrisma.cardLink.findUnique.mockResolvedValue(null); // not already linked + mockPrisma.platformLink.findFirst.mockResolvedValue({ id: OWNED_LINK_ID, userId: USER_ID }); + mockPrisma.cardLink.create.mockResolvedValue({ id: 'cl-1', cardId: CARD_ID, platformLinkId: OWNED_LINK_ID }); const app = await buildApp(); const res = await app.inject({ method: 'PUT', - url: `/api/cards/${CARD_ID}`, - payload: { linkIds: [OWNED_LINK_ID] }, + url: `/api/cards/${CARD_ID}/platform-link`, + payload: { platformLinkId: OWNED_LINK_ID }, }); expect(res.statusCode).toBe(200); - expect(mockPrisma.platformLink.findMany).toHaveBeenCalledWith({ - where: { id: { in: [OWNED_LINK_ID] }, userId: USER_ID }, - select: { id: true }, + expect(mockPrisma.cardLink.create).toHaveBeenCalledWith({ + data: { cardId: CARD_ID, platformLinkId: OWNED_LINK_ID }, }); - // Both operations must run inside the transaction, not as bare queries - expect(mockPrisma.$transaction).toHaveBeenCalledOnce(); - expect(mockPrisma.cardLink.deleteMany).toHaveBeenCalledWith({ where: { cardId: CARD_ID } }); - expect(mockPrisma.cardLink.createMany).toHaveBeenCalled(); }); - it('returns 404 when the card does not belong to the user', async () => { + it('returns 404 when the card is not owned by the user', async () => { mockPrisma.card.findFirst.mockResolvedValue(null); const app = await buildApp(); const res = await app.inject({ method: 'PUT', - url: `/api/cards/${CARD_ID}`, - payload: { linkIds: [OWNED_LINK_ID] }, + url: `/api/cards/${CARD_ID}/platform-link`, + payload: { platformLinkId: OWNED_LINK_ID }, }); expect(res.statusCode).toBe(404); - expect(mockPrisma.platformLink.findMany).not.toHaveBeenCalled(); + expect(mockPrisma.cardLink.create).not.toHaveBeenCalled(); }); - it('returns 500 when the ownership query throws and no mutation occurs', async () => { + it('returns 403 when the platform link does not belong to the user', async () => { mockPrisma.card.findFirst.mockResolvedValue(mockCard); - mockPrisma.platformLink.findMany.mockRejectedValue(new Error('DB timeout')); + mockPrisma.cardLink.findUnique.mockResolvedValue(null); + mockPrisma.platformLink.findFirst.mockResolvedValue(null); // foreign / missing link const app = await buildApp(); const res = await app.inject({ method: 'PUT', - url: `/api/cards/${CARD_ID}`, - payload: { linkIds: [OWNED_LINK_ID] }, + url: `/api/cards/${CARD_ID}/platform-link`, + payload: { platformLinkId: FOREIGN_LINK_ID }, }); - expect(res.statusCode).toBe(500); - expect(mockPrisma.$transaction).not.toHaveBeenCalled(); - expect(mockPrisma.cardLink.deleteMany).not.toHaveBeenCalled(); + expect(res.statusCode).toBe(403); + expect(mockPrisma.cardLink.create).not.toHaveBeenCalled(); }); - it('returns 500 and preserves existing links when the transaction fails mid-flight', async () => { - // Ownership check passes; deleteMany succeeds; createMany fails. - // The transaction rolls back, so the card retains its original links. + it('returns 409 when the platform link is already on the card', async () => { mockPrisma.card.findFirst.mockResolvedValue(mockCard); - mockPrisma.platformLink.findMany.mockResolvedValue([{ id: OWNED_LINK_ID }]); - mockPrisma.cardLink.deleteMany.mockResolvedValue({ count: 1 }); - mockPrisma.cardLink.createMany.mockRejectedValue(new Error('FK constraint')); + mockPrisma.cardLink.findUnique.mockResolvedValue({ id: 'cl-existing' }); + mockPrisma.platformLink.findFirst.mockResolvedValue({ id: OWNED_LINK_ID, userId: USER_ID }); const app = await buildApp(); const res = await app.inject({ method: 'PUT', - url: `/api/cards/${CARD_ID}`, - payload: { linkIds: [OWNED_LINK_ID] }, + url: `/api/cards/${CARD_ID}/platform-link`, + payload: { platformLinkId: OWNED_LINK_ID }, }); - expect(res.statusCode).toBe(500); - // Both were attempted inside the transaction (the DB rolls them back together) - expect(mockPrisma.cardLink.deleteMany).toHaveBeenCalled(); - expect(mockPrisma.cardLink.createMany).toHaveBeenCalled(); - // The final read must not have been called -- we short-circuited on error - expect(mockPrisma.card.findUnique).not.toHaveBeenCalled(); + expect(res.statusCode).toBe(409); + expect(mockPrisma.cardLink.create).not.toHaveBeenCalled(); }); - it('returns 500 when card.findFirst throws', async () => { - mockPrisma.card.findFirst.mockRejectedValue(new Error('Connection refused')); - + it('returns 400 when platformLinkId is not a valid UUID', async () => { const app = await buildApp(); const res = await app.inject({ method: 'PUT', - url: `/api/cards/${CARD_ID}`, - payload: { linkIds: [OWNED_LINK_ID] }, + url: `/api/cards/${CARD_ID}/platform-link`, + payload: { platformLinkId: 'not-a-uuid' }, }); - expect(res.statusCode).toBe(500); + expect(res.statusCode).toBe(400); + expect(mockPrisma.card.findFirst).not.toHaveBeenCalled(); }); }); // ───────────────────────────────────────────────────────────────────────────── -// DELETE /api/cards/:id +// DELETE /api/cards/:id/delete // ───────────────────────────────────────────────────────────────────────────── -describe('DELETE /api/cards/:id', () => { +describe('DELETE /api/cards/:id/delete', () => { beforeEach(() => { vi.clearAllMocks(); wireTransaction(); @@ -318,7 +420,7 @@ describe('DELETE /api/cards/:id', () => { mockPrisma.card.delete.mockResolvedValue(mockCard); const app = await buildApp(); - const res = await app.inject({ method: 'DELETE', url: `/api/cards/${CARD_ID}` }); + const res = await app.inject({ method: 'DELETE', url: `/api/cards/${CARD_ID}/delete` }); expect(res.statusCode).toBe(204); expect(mockPrisma.card.delete).toHaveBeenCalledWith({ where: { id: CARD_ID } }); @@ -337,7 +439,7 @@ describe('DELETE /api/cards/:id', () => { mockPrisma.card.delete.mockResolvedValue(mockCard); const app = await buildApp(); - const res = await app.inject({ method: 'DELETE', url: `/api/cards/${CARD_ID}` }); + const res = await app.inject({ method: 'DELETE', url: `/api/cards/${CARD_ID}/delete` }); expect(res.statusCode).toBe(204); expect(mockPrisma.card.update).toHaveBeenCalledWith({ @@ -351,7 +453,7 @@ describe('DELETE /api/cards/:id', () => { mockPrisma.card.findFirst.mockResolvedValue(null); const app = await buildApp(); - const res = await app.inject({ method: 'DELETE', url: `/api/cards/${CARD_ID}` }); + const res = await app.inject({ method: 'DELETE', url: `/api/cards/${CARD_ID}/delete` }); expect(res.statusCode).toBe(404); expect(mockPrisma.card.delete).not.toHaveBeenCalled(); @@ -362,7 +464,7 @@ describe('DELETE /api/cards/:id', () => { mockPrisma.card.count.mockResolvedValue(1); const app = await buildApp(); - const res = await app.inject({ method: 'DELETE', url: `/api/cards/${CARD_ID}` }); + const res = await app.inject({ method: 'DELETE', url: `/api/cards/${CARD_ID}/delete` }); expect(res.statusCode).toBe(400); expect(res.json().error).toBe('Cannot delete the last remaining card. A user must have at least one card.'); @@ -375,7 +477,7 @@ describe('DELETE /api/cards/:id', () => { mockPrisma.card.delete.mockRejectedValue(new Error('Deadlock detected')); const app = await buildApp(); - const res = await app.inject({ method: 'DELETE', url: `/api/cards/${CARD_ID}` }); + const res = await app.inject({ method: 'DELETE', url: `/api/cards/${CARD_ID}/delete` }); expect(res.statusCode).toBe(500); }); @@ -400,7 +502,7 @@ describe('PUT /api/cards/:id/default', () => { const res = await app.inject({ method: 'PUT', url: `/api/cards/${CARD_ID}/default` }); expect(res.statusCode).toBe(200); - expect(res.json().message).toBe('Default card updated'); + expect(res.body).toBe('Default card updated'); expect(mockPrisma.$transaction).toHaveBeenCalledOnce(); // Clear-all and set-one must both run inside the transaction expect(mockPrisma.card.updateMany).toHaveBeenCalledWith({ @@ -438,3 +540,162 @@ describe('PUT /api/cards/:id/default', () => { expect(mockPrisma.card.update).toHaveBeenCalled(); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// POST /api/cards/:id/share +// ───────────────────────────────────────────────────────────────────────────── + +describe('POST /api/cards/:id/share', () => { + beforeEach(() => { + vi.clearAllMocks(); + wireTransaction(); + }); + + it('returns 200 with a share URL for a non-private owned card', async () => { + mockPrisma.card.findFirst.mockResolvedValue(mockCard); + + const app = await buildApp(); + const res = await app.inject({ method: 'POST', url: `/api/cards/${CARD_ID}/share` }); + + expect(res.statusCode).toBe(200); + expect(res.json().shareUrl).toBe(`/cards/share/${mockCard.slug}`); + }); + + it('returns 404 when the card is not owned by the user', async () => { + mockPrisma.card.findFirst.mockResolvedValue(null); + + const app = await buildApp(); + const res = await app.inject({ method: 'POST', url: `/api/cards/${CARD_ID}/share` }); + + expect(res.statusCode).toBe(404); + }); + + it('returns 403 when the card is private', async () => { + mockPrisma.card.findFirst.mockResolvedValue({ ...mockCard, visibility: CardVisibility.PRIVATE }); + + const app = await buildApp(); + const res = await app.inject({ method: 'POST', url: `/api/cards/${CARD_ID}/share` }); + + expect(res.statusCode).toBe(403); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// GET /api/cards/share/:slug +// ───────────────────────────────────────────────────────────────────────────── + +describe('GET /api/cards/share/:slug', () => { + beforeEach(() => { + vi.clearAllMocks(); + wireTransaction(); + }); + + it('returns 200 and records a view for an existing shared card', async () => { + const sharedCard = { ...mockCard, cardLinks: [] }; + mockPrisma.card.findUnique.mockResolvedValue(sharedCard); + mockPrisma.card.update.mockResolvedValue(sharedCard); + mockPrisma.cardView.create.mockResolvedValue({ id: 'view-1' }); + + const app = await buildApp(); + const res = await app.inject({ method: 'GET', url: `/api/cards/share/${mockCard.slug}` }); + + expect(res.statusCode).toBe(200); + // View tracking runs in the sequential transaction: increment count + log view + expect(mockPrisma.$transaction).toHaveBeenCalledOnce(); + expect(mockPrisma.card.update).toHaveBeenCalledWith({ + where: { id: mockCard.id }, + data: { viewCount: { increment: 1 } }, + }); + expect(mockPrisma.cardView.create).toHaveBeenCalled(); + }); + + it('returns 404 when no card matches the slug', async () => { + mockPrisma.card.findUnique.mockResolvedValue(null); + + const app = await buildApp(); + const res = await app.inject({ method: 'GET', url: '/api/cards/share/missing-slug' }); + + expect(res.statusCode).toBe(404); + expect(mockPrisma.$transaction).not.toHaveBeenCalled(); + expect(mockPrisma.cardView.create).not.toHaveBeenCalled(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// GET /api/cards/:id/qr +// ───────────────────────────────────────────────────────────────────────────── + +describe('GET /api/cards/:id/qr', () => { + beforeEach(() => { + vi.clearAllMocks(); + wireTransaction(); + process.env.MOBILE_REDIRECT_URI = 'https://devcard.test'; + }); + + it('returns 200 with a PNG image for a shareable, qr-enabled card', async () => { + mockPrisma.card.findFirst.mockResolvedValue(mockCard); + + const app = await buildApp(); + const res = await app.inject({ method: 'GET', url: `/api/cards/${CARD_ID}/qr` }); + + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toContain('image/png'); + }); + + it('returns 404 when the card is not owned by the user', async () => { + mockPrisma.card.findFirst.mockResolvedValue(null); + + const app = await buildApp(); + const res = await app.inject({ method: 'GET', url: `/api/cards/${CARD_ID}/qr` }); + + expect(res.statusCode).toBe(404); + }); + + it('returns 403 when the card is private', async () => { + mockPrisma.card.findFirst.mockResolvedValue({ ...mockCard, visibility: CardVisibility.PRIVATE }); + + const app = await buildApp(); + const res = await app.inject({ method: 'GET', url: `/api/cards/${CARD_ID}/qr` }); + + expect(res.statusCode).toBe(403); + }); + + it('returns 403 when QR is disabled for the card', async () => { + mockPrisma.card.findFirst.mockResolvedValue({ ...mockCard, qrEnabled: false }); + + const app = await buildApp(); + const res = await app.inject({ method: 'GET', url: `/api/cards/${CARD_ID}/qr` }); + + expect(res.statusCode).toBe(403); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// GET /api/cards/:id/analytics +// ───────────────────────────────────────────────────────────────────────────── + +describe('GET /api/cards/:id/analytics', () => { + beforeEach(() => { + vi.clearAllMocks(); + wireTransaction(); + }); + + it('returns 200 with the card and its views', async () => { + mockPrisma.card.findFirst.mockResolvedValue({ ...mockCard, views: [] }); + + const app = await buildApp(); + const res = await app.inject({ method: 'GET', url: `/api/cards/${CARD_ID}/analytics` }); + + expect(res.statusCode).toBe(200); + expect(mockPrisma.card.findFirst).toHaveBeenCalled(); + }); + + it('returns 404 when the card is not owned by the user', async () => { + mockPrisma.card.findFirst.mockResolvedValue(null); + + const app = await buildApp(); + const res = await app.inject({ method: 'GET', url: `/api/cards/${CARD_ID}/analytics` }); + + expect(res.statusCode).toBe(404); + }); +}); diff --git a/apps/backend/src/__tests__/connect.test.ts b/apps/backend/src/__tests__/connect.test.ts index 371cec7f..8e8604c3 100644 --- a/apps/backend/src/__tests__/connect.test.ts +++ b/apps/backend/src/__tests__/connect.test.ts @@ -1,39 +1,310 @@ -import { describe, it, expect } from 'vitest'; +import jwt from '@fastify/jwt'; +import Fastify from 'fastify'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; -// Mock test for GitHub OAuth callback state validation -// Note: This test verifies the expected behavior of the -// /api/connect/github/callback endpoint when invalid or -// malformed OAuth state values are received. -// -// The implementation in connect.ts now: -// - safely parses OAuth state via parseGoogleState() -// - validates required fields (userId + nonce) -// - redirects invalid callbacks safely -// -// Security note: -// OAuth state validation helps prevent tampered callback -// requests and malformed state payload attacks. +import { connectRoutes } from '../routes/connect.js'; +import { encrypt } from '../utils/encryption.js'; -describe('GET /api/connect/github/callback - Invalid OAuth State', () => { +import type { PrismaClient } from '@prisma/client'; - it('should redirect with connect_failed when state is invalid', async () => { - // Expected behavior: - // parseGoogleState('invalid_state') -> null - // reply.redirect(`${PUBLIC_APP_URL}/settings?error=connect_failed`) +process.env.PUBLIC_APP_URL = 'http://localhost:3000'; +process.env.BACKEND_URL = 'http://localhost:3001'; +process.env.MOBILE_REDIRECT_URI = 'devcard://connect'; +process.env.GITHUB_CLIENT_ID = 'test-client-id'; +process.env.GITHUB_CLIENT_SECRET = 'test-client-secret'; +process.env.ENCRYPTION_KEY = '12345678901234567890123456789012'; - expect(true).toBe(true); +const mockRedis = { + get: vi.fn(), + set: vi.fn(), + del: vi.fn(), +}; + +const mockPrisma = { + oAuthToken: { + findMany: vi.fn(), + findUnique: vi.fn(), + upsert: vi.fn(), + delete: vi.fn(), + }, +}; + +global.fetch = vi.fn(); + +async function buildApp(): Promise> { + const app = Fastify(); + await app.register(jwt, { secret: 'test-secret' }); + app.decorate('prisma', mockPrisma as unknown as PrismaClient); + app.decorate('redis', mockRedis as any); + + app.decorate('authenticate', async (request: any, reply: any) => { + try { + await request.jwtVerify(); + } catch { + reply.status(401).send({ error: 'Unauthorized' }); + } + }); + + app.register(connectRoutes, { prefix: '/api/connect' }); + await app.ready(); + return app; +} + +function authHeader(app: any): { authorization: string } { + return { authorization: `Bearer ${app.jwt.sign({ id: 'user-1' })}` }; +} + +describe('GET /api/connect/github/callback', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('redirects with missing_params if code or state is missing', async () => { + const app = await buildApp(); + + // Missing code + let res = await app.inject({ + method: 'GET', + url: '/api/connect/github/callback?state=somestate', + }); + expect(res.statusCode).toBe(302); + expect(res.headers.location).toBe('http://localhost:3000/settings?error=missing_params'); + + // Missing state + res = await app.inject({ + method: 'GET', + url: '/api/connect/github/callback?code=somecode', + }); + expect(res.statusCode).toBe(302); + expect(res.headers.location).toBe('http://localhost:3000/settings?error=missing_params'); + }); + + it('redirects with connect_failed if state is invalid/malformed', async () => { + const app = await buildApp(); + const invalidState = Buffer.from(JSON.stringify({ wrongKey: 'value' })).toString('base64'); + + const res = await app.inject({ + method: 'GET', + url: `/api/connect/github/callback?code=testcode&state=${invalidState}`, + }); + + expect(res.statusCode).toBe(302); + expect(res.headers.location).toBe('http://localhost:3000/settings?error=connect_failed'); + }); + + it('redirects with invalid_state if nonce is not found in Redis (CSRF/Expired)', async () => { + mockRedis.get.mockResolvedValue(null); + const app = await buildApp(); + const validState = Buffer.from(JSON.stringify({ userId: 'user-1', nonce: 'nonce-123' })).toString('base64'); + + const res = await app.inject({ + method: 'GET', + url: `/api/connect/github/callback?code=testcode&state=${validState}`, + }); + + expect(mockRedis.get).toHaveBeenCalledWith('oauth:nonce:nonce-123'); + expect(res.statusCode).toBe(302); + expect(res.headers.location).toBe('http://localhost:3000/settings?error=invalid_state'); + }); + + it('redirects with invalid_state if Redis userId does not match state userId', async () => { + mockRedis.get.mockResolvedValue('different-user-id'); + const app = await buildApp(); + const validState = Buffer.from(JSON.stringify({ userId: 'user-1', nonce: 'nonce-123' })).toString('base64'); + + const res = await app.inject({ + method: 'GET', + url: `/api/connect/github/callback?code=testcode&state=${validState}`, + }); + + expect(res.statusCode).toBe(302); + expect(res.headers.location).toBe('http://localhost:3000/settings?error=invalid_state'); + }); + + it('successfully exchanges code, upserts token, and redirects on valid flow (Web)', async () => { + mockRedis.get.mockResolvedValue('user-1'); + (global.fetch as any).mockResolvedValue({ + json: vi.fn().mockResolvedValue({ access_token: 'github-access-token', scope: 'user:follow' }) + }); + mockPrisma.oAuthToken.upsert.mockResolvedValue({}); + + const app = await buildApp(); + const validState = Buffer.from(JSON.stringify({ userId: 'user-1', nonce: 'web_nonce-123' })).toString('base64'); + + const res = await app.inject({ + method: 'GET', + url: `/api/connect/github/callback?code=testcode&state=${validState}`, + }); + + // Nonce should be deleted immediately + expect(mockRedis.del).toHaveBeenCalledWith('oauth:nonce:web_nonce-123'); + + // Code exchange should be triggered + expect(global.fetch).toHaveBeenCalledWith('https://github.com/login/oauth/access_token', expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('testcode') + })); + + // Upsert should be called + expect(mockPrisma.oAuthToken.upsert).toHaveBeenCalledWith(expect.objectContaining({ + where: { userId_platform: { userId: 'user-1', platform: 'github_follow' } } + })); + + // Redirects to web success + expect(res.statusCode).toBe(302); + expect(res.headers.location).toBe('http://localhost:3000/settings?connected=github'); + }); + + it('redirects to mobile scheme if nonce starts with mobile_', async () => { + mockRedis.get.mockResolvedValue('user-1'); + (global.fetch as any).mockResolvedValue({ + json: vi.fn().mockResolvedValue({ access_token: 'github-access-token', scope: 'user:follow' }) + }); + mockPrisma.oAuthToken.upsert.mockResolvedValue({}); + + const app = await buildApp(); + const validState = Buffer.from(JSON.stringify({ userId: 'user-1', nonce: 'mobile_nonce-123' })).toString('base64'); + + const res = await app.inject({ + method: 'GET', + url: `/api/connect/github/callback?code=testcode&state=${validState}`, + }); + + expect(res.statusCode).toBe(302); + expect(res.headers.location).toBe('devcard://connect?connected=github'); + }); + + it('redirects with connect_failed if token exchange returns an error', async () => { + mockRedis.get.mockResolvedValue('user-1'); + (global.fetch as any).mockResolvedValue({ + json: vi.fn().mockResolvedValue({ error: 'bad_verification_code' }) + }); + + const app = await buildApp(); + const validState = Buffer.from(JSON.stringify({ userId: 'user-1', nonce: 'nonce-123' })).toString('base64'); + + const res = await app.inject({ + method: 'GET', + url: `/api/connect/github/callback?code=testcode&state=${validState}`, + }); + + expect(mockPrisma.oAuthToken.upsert).not.toHaveBeenCalled(); + expect(res.statusCode).toBe(302); + expect(res.headers.location).toBe('http://localhost:3000/settings?error=connect_failed'); + }); + + it('returns cached discovery suggestions when Redis stores the response', async () => { + const cachedResponse = [{ platform: 'twitter', username: 'octocat', confidence: 'high' }]; + mockRedis.get.mockResolvedValue(JSON.stringify(cachedResponse)); + + const app = await buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/connect/github/autodiscover', + headers: authHeader(app), + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual(cachedResponse); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('returns discovery suggestions and caches the result', async () => { + mockRedis.get.mockResolvedValue(null); + mockPrisma.oAuthToken.findUnique.mockResolvedValue({ accessToken: encrypt('github-access-token') }); + (global.fetch as any).mockResolvedValue({ + ok: true, + status: 200, + json: vi.fn().mockResolvedValue({ + twitter_username: 'octocat', + blog: 'https://dev.to/octocat', + company: 'GitHub', + bio: 'Developer', + html_url: 'https://github.com/octocat', + }), + }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/connect/github/autodiscover', + headers: authHeader(app), }); - it('should reject malformed oauth state payloads', async () => { - // Example malformed payload: - // { invalid: true } - // - // Expected: - // - missing userId - // - missing nonce - // - redirect to connect_failed + const expected = [ + { platform: 'twitter', username: 'octocat', confidence: 'high' }, + { platform: 'devto', username: 'octocat', confidence: 'low' }, + ]; + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual(expected); + expect(mockRedis.set).toHaveBeenCalledWith('github:autodiscover:user-1', JSON.stringify(expected), 'EX', 3600); + }); + + it('returns unauthorized when GitHub API returns 401', async () => { + mockRedis.get.mockResolvedValue(null); + mockPrisma.oAuthToken.findUnique.mockResolvedValue({ accessToken: encrypt('github-access-token') }); + (global.fetch as any).mockResolvedValue({ + ok: false, + status: 401, + text: vi.fn().mockResolvedValue('Bad credentials'), + }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/connect/github/autodiscover', + headers: authHeader(app), + }); + + expect(res.statusCode).toBe(401); + expect(res.json()).toEqual({ error: 'GitHub token expired or revoked', requiresAuth: true }); + expect(mockRedis.del).toHaveBeenCalledWith('github:autodiscover:user-1'); + }); + + it('returns an error when the GitHub follow token is missing', async () => { + mockRedis.get.mockResolvedValue(null); + mockPrisma.oAuthToken.findUnique.mockResolvedValue(null); + + const app = await buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/connect/github/autodiscover', + headers: authHeader(app), + }); + + expect(res.statusCode).toBe(400); + expect(res.json()).toEqual({ error: 'Not connected to GitHub. Please connect GitHub first.', requiresAuth: true }); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('falls back to live GitHub discovery when Redis read fails', async () => { + mockRedis.get.mockRejectedValue(new Error('Redis unavailable')); + mockPrisma.oAuthToken.findUnique.mockResolvedValue({ accessToken: encrypt('github-access-token') }); + (global.fetch as any).mockResolvedValue({ + ok: true, + status: 200, + json: vi.fn().mockResolvedValue({ + twitter_username: 'octocat', + blog: 'https://npmjs.com/~octocat', + company: 'GitHub', + bio: 'Developer', + html_url: 'https://github.com/octocat', + }), + }); - expect(true).toBe(true); + const app = await buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/connect/github/autodiscover', + headers: authHeader(app), }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual([ + { platform: 'twitter', username: 'octocat', confidence: 'high' }, + { platform: 'npm', username: 'octocat', confidence: 'low' }, + ]); + expect(global.fetch).toHaveBeenCalled(); + }); }); \ No newline at end of file diff --git a/apps/backend/src/__tests__/logout.test.ts b/apps/backend/src/__tests__/logout.test.ts new file mode 100644 index 00000000..fbfa685b --- /dev/null +++ b/apps/backend/src/__tests__/logout.test.ts @@ -0,0 +1,673 @@ +import cookiePlugin from '@fastify/cookie'; +import jwtPlugin from '@fastify/jwt'; +import Fastify, { type FastifyInstance, type FastifyRequest } from 'fastify'; +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; + +import { authRoutes } from '../routes/auth.js'; +import { extractRawJwt, blocklistKey } from '../utils/jwt.js'; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const TEST_JWT_SECRET = 'test-secret-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'; // ≥ 32 chars +const USER_ID = 'user-test-001'; +const USERNAME = 'testuser'; + +// ─── Mock Redis factory ─────────────────────────────────────────────────────── + +function createMockRedis(): { exists: Mock; set: Mock; del: Mock } { + return { + exists: vi.fn().mockResolvedValue(0), + set: vi.fn().mockResolvedValue('OK'), + del: vi.fn().mockResolvedValue(1), + }; +} + +type MockRedis = ReturnType; + +// ─── App factory ───────────────────────────────────────────────────────────── +// +// Builds an isolated Fastify instance that mirrors the production authenticate +// decorator (blocklist check → jwtVerify) without needing a real database or +// Redis server. All dependencies are replaced with vitest mocks. + +async function buildTestApp(mockRedis: MockRedis): Promise { + const app = Fastify({ logger: false }); + + // cookie must be registered before jwt (required by @fastify/jwt when the + // cookie option is used) so that request.cookies is populated before + // jwtVerify() runs. + // + // Both plugins use `export =` (CJS-style) declarations. TypeScript resolves + // the overloaded type as the namespace object rather than the callable + // function when moduleResolution is "bundler", so `as any` narrows to the + // call signature Fastify's register() actually expects at runtime. + await app.register(cookiePlugin as any); + // Real JWT plugin with cookie support — mirrors the production configuration + // in app.ts so that both Authorization header and token cookie are accepted. + await app.register(jwtPlugin as any, { + secret: TEST_JWT_SECRET, + cookie: { cookieName: 'access_Token', signed: false }, + }); + + // Minimal Prisma stub. The logout route does not touch the database, but + // authRoutes also registers /dev-login and /auth/me which reference + // app.prisma at request time (never reached by these tests). + app.decorate('prisma', { + user: { findUnique: vi.fn().mockResolvedValue(null) }, + } as any); + + // Mock Redis — injected so the authenticate decorator and logout handler + // can interact with it without a real Redis server. + app.decorate('redis', mockRedis as any); + + // Authenticate decorator — mirrors production logic in app.ts: + // 1. Extract raw JWT. + // 2. Check blocklist in Redis (inner try/catch — Redis failure is non-fatal). + // 3. Call jwtVerify() (outer try/catch — invalid JWT → 401). + app.decorate('authenticate', async function (request: any, reply: any) { + try { + const raw = extractRawJwt(request); + if (raw) { + try { + const revoked = await mockRedis.exists(blocklistKey(raw)); + if (revoked) { + return reply.status(401).send({ error: 'Token has been revoked' }); + } + } catch (redisErr) { + app.log.warn({ err: redisErr }, 'Redis blocklist check failed — proceeding with JWT verification'); + } + } + await request.jwtVerify(); + } catch { + return reply.status(401).send({ error: 'Unauthorized' }); + } + }); + + await app.register(authRoutes, { prefix: '/auth' }); + + // Generic protected route — used to test the authenticate middleware + // independently of the logout handler. + app.get('/protected', { + preHandler: [(app as any).authenticate], + }, async () => ({ ok: true })); + + await app.ready(); + return app; +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function bearerHeader(token: string): { Authorization: string } { + return { Authorization: `Bearer ${token}` }; +} + +// app.jwt is added by @fastify/jwt's module augmentation. The augmentation +// is not picked up by VS Code's language server under moduleResolution:"bundler" +// for `export =` packages, so all sign() calls go through this helper to keep +// the single cast in one place rather than scattering `(app as any)` everywhere. +function signToken(app: FastifyInstance, payload: object, options?: Record): string { + return (app as any).jwt.sign(payload, options); +} + +// ─── DELETE /auth/logout ────────────────────────────────────────────────────── + +describe('DELETE /auth/logout', () => { + let app: FastifyInstance; + let mockRedis: MockRedis; + + beforeEach(async () => { + vi.clearAllMocks(); + mockRedis = createMockRedis(); + app = await buildTestApp(mockRedis); + }); + + afterEach(async () => { + await app.close(); + }); + + it('200 — returns logged-out message and clears the token cookie', async () => { + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + + const res = await app.inject({ + method: 'DELETE', + url: '/auth/logout', + headers: bearerHeader(token), + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ message: 'Logged out' }); + + // Cookie must be cleared — Set-Cookie header should zero the token value. + const setCookie = res.headers['set-cookie'] as string | string[]; + const cookieStr = Array.isArray(setCookie) ? setCookie.join('; ') : setCookie; + expect(cookieStr).toMatch(/token=;/); + }); + + it('blocks the token in Redis with a positive TTL', async () => { + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + + await app.inject({ + method: 'DELETE', + url: '/auth/logout', + headers: bearerHeader(token), + }); + + expect(mockRedis.set).toHaveBeenCalledOnce(); + + const [key, value, exFlag, ttl] = mockRedis.set.mock.calls[0] as unknown as [string, string, string, number]; + expect(key).toBe(blocklistKey(token)); + expect(value).toBe('1'); + expect(exFlag).toBe('EX'); + // TTL should be close to 30 days in seconds (allow 60s of test execution slack). + expect(ttl).toBeGreaterThan(30 * 24 * 60 * 60 - 60); + expect(ttl).toBeLessThanOrEqual(30 * 24 * 60 * 60); + }); + + it('uses the correct blocklist key derived from the token signature', async () => { + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + + await app.inject({ + method: 'DELETE', + url: '/auth/logout', + headers: bearerHeader(token), + }); + + const [key] = mockRedis.set.mock.calls[0] as unknown as [string]; + expect(key).toBe(blocklistKey(token)); + // Key must be a deterministic sha256 hash, never the raw JWT. + expect(key).toMatch(/^blocklist:[0-9a-f]{64}$/); + expect(key).not.toContain(token); + }); + + it('401 — rejects request with no token (unauthenticated)', async () => { + const res = await app.inject({ + method: 'DELETE', + url: '/auth/logout', + }); + + expect(res.statusCode).toBe(401); + expect(mockRedis.set).not.toHaveBeenCalled(); + }); + + it('401 — rejects request with a malformed token', async () => { + const res = await app.inject({ + method: 'DELETE', + url: '/auth/logout', + headers: bearerHeader('not.a.valid.jwt'), + }); + + expect(res.statusCode).toBe(401); + expect(mockRedis.set).not.toHaveBeenCalled(); + }); + + it('still returns 200 if Redis write fails (non-fatal)', async () => { + mockRedis.set.mockRejectedValueOnce(new Error('Redis connection lost')); + + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + + const res = await app.inject({ + method: 'DELETE', + url: '/auth/logout', + headers: bearerHeader(token), + }); + + // Logout must succeed even when Redis is down — cookie is still cleared. + expect(res.statusCode).toBe(200); + }); + + it('401 — rejects a second logout attempt with an already-revoked token', async () => { + // After the first logout the token is in the blocklist (exists returns 1). + mockRedis.exists.mockResolvedValue(1); + + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + + const res = await app.inject({ + method: 'DELETE', + url: '/auth/logout', + headers: bearerHeader(token), + }); + + // The authenticate preHandler catches the revoked token before the handler runs. + expect(res.statusCode).toBe(401); + expect(res.json().error).toBe('Token has been revoked'); + // Redis write must NOT be called — handler never ran. + expect(mockRedis.set).not.toHaveBeenCalled(); + }); + + it('401 — expired token is rejected and does not write to Redis', async () => { + const realNow = Date.now(); + // Sign with 1-second expiry so we can advance the clock past it. + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: 1 }); + + // Fake only the Date object (not timers) so jwtVerify sees the token as + // expired without blocking the async inject pipeline. + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(realNow + 2000); + + try { + const res = await app.inject({ + method: 'DELETE', + url: '/auth/logout', + headers: bearerHeader(token), + }); + // Authenticate preHandler rejects the expired token; handler never runs. + expect(res.statusCode).toBe(401); + expect(mockRedis.set).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('200 — works when JWT is sent via cookie (web browser flow)', async () => { + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + + const res = await app.inject({ + method: 'DELETE', + url: '/auth/logout', + headers: { Cookie: `access_Token=${token}` }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ message: 'Logged out' }); + // Token extracted from cookie must still be blocklisted in Redis. + expect(mockRedis.set).toHaveBeenCalledOnce(); + const [key] = mockRedis.set.mock.calls[0] as unknown as [string]; + expect(key).toBe(blocklistKey(token)); + }); + + it('200 — Authorization header takes precedence over cookie when both are present', async () => { + const headerToken = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + const cookieToken = signToken(app, { id: 'other-user', username: 'other' }, { expiresIn: '30d' }); + + const res = await app.inject({ + method: 'DELETE', + url: '/auth/logout', + headers: { + Authorization: `Bearer ${headerToken}`, + Cookie: `access_Token=${cookieToken}`, + }, + }); + + expect(res.statusCode).toBe(200); + // The header token must be blocklisted — not the cookie token. + expect(mockRedis.set).toHaveBeenCalledOnce(); + const [key] = mockRedis.set.mock.calls[0] as unknown as [string]; + expect(key).toBe(blocklistKey(headerToken)); + expect(key).not.toBe(blocklistKey(cookieToken)); + }); + + it('200 — Set-Cookie response clears token with Path=/ and a past Expires date', async () => { + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + + const res = await app.inject({ + method: 'DELETE', + url: '/auth/logout', + headers: bearerHeader(token), + }); + + expect(res.statusCode).toBe(200); + const raw = res.headers['set-cookie'] as string | string[]; + const cookieStr = Array.isArray(raw) ? raw.join('; ') : (raw ?? ''); + // Value must be emptied. + expect(cookieStr).toMatch(/access_Token=;/); + // Path must be explicit so the browser clears the cookie on all routes. + expect(cookieStr).toMatch(/Path=\//i); + // Browser must be told to delete the cookie immediately. + expect(cookieStr).toMatch(/Expires=|Max-Age=0/i); + }); + + it('200 — near-expiry token gets a short positive TTL in Redis', async () => { + // Token that expires in 5 seconds — the blocklist TTL must still be positive. + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: 5 }); + + const res = await app.inject({ + method: 'DELETE', + url: '/auth/logout', + headers: bearerHeader(token), + }); + + expect(res.statusCode).toBe(200); + expect(mockRedis.set).toHaveBeenCalledOnce(); + const [, , , ttl] = mockRedis.set.mock.calls[0] as unknown as [string, string, string, number]; + expect(ttl).toBeGreaterThan(0); + expect(ttl).toBeLessThanOrEqual(5); + }); + + it('200 — logs warning and skips Redis write when JWT has no exp claim', async () => { + // Signing without expiresIn produces a token with no exp field. + const token = signToken(app, { id: USER_ID, username: USERNAME }); + const warnMock = vi.fn(); + // Replace the logger's warn method so we can assert it was called. + (app.log as any).warn = warnMock; + + const res = await app.inject({ + method: 'DELETE', + url: '/auth/logout', + headers: bearerHeader(token), + }); + + expect(res.statusCode).toBe(200); + expect(mockRedis.set).not.toHaveBeenCalled(); + expect(warnMock).toHaveBeenCalledOnce(); + // Verify the message identifies the root cause clearly. + const [message] = warnMock.mock.calls[0] as [string]; + expect(message).toMatch(/missing exp/i); + }); + + it('401 — rejects "Authorization: Bearer " with no token value after the prefix', async () => { + const res = await app.inject({ + method: 'DELETE', + url: '/auth/logout', + headers: { Authorization: 'Bearer ' }, + }); + + expect(res.statusCode).toBe(401); + expect(mockRedis.set).not.toHaveBeenCalled(); + }); +}); + +// ─── authenticate middleware — blocklist behaviour ──────────────────────────── + +describe('authenticate middleware', () => { + let app: FastifyInstance; + let mockRedis: MockRedis; + + beforeEach(async () => { + vi.clearAllMocks(); + mockRedis = createMockRedis(); + app = await buildTestApp(mockRedis); + }); + + afterEach(async () => { + await app.close(); + }); + + it('200 — allows a valid non-revoked token', async () => { + mockRedis.exists.mockResolvedValue(0); + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + + const res = await app.inject({ + method: 'GET', + url: '/protected', + headers: bearerHeader(token), + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ ok: true }); + expect(mockRedis.exists).toHaveBeenCalledOnce(); + expect(mockRedis.exists.mock.calls[0][0]).toBe(blocklistKey(token)); + }); + + it('401 — rejects a revoked token with "Token has been revoked"', async () => { + mockRedis.exists.mockResolvedValue(1); // token is in the blocklist + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + + const res = await app.inject({ + method: 'GET', + url: '/protected', + headers: bearerHeader(token), + }); + + expect(res.statusCode).toBe(401); + expect(res.json().error).toBe('Token has been revoked'); + }); + + it('200 — continues to allow access when Redis check throws (fail-open)', async () => { + mockRedis.exists.mockRejectedValueOnce(new Error('Redis timeout')); + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + + const res = await app.inject({ + method: 'GET', + url: '/protected', + headers: bearerHeader(token), + }); + + // Redis failure must not cause a false 401 — JWT expiry is still the guard. + expect(res.statusCode).toBe(200); + }); + + it('401 — rejects a malformed token with "Unauthorized"', async () => { + const res = await app.inject({ + method: 'GET', + url: '/protected', + headers: bearerHeader('not-a-jwt'), + }); + + expect(res.statusCode).toBe(401); + expect(res.json().error).toBe('Unauthorized'); + }); + + it('401 — rejects a request with no token', async () => { + const res = await app.inject({ + method: 'GET', + url: '/protected', + }); + + expect(res.statusCode).toBe(401); + expect(mockRedis.exists).not.toHaveBeenCalled(); + }); + + it('401 — rejects a token signed with the wrong secret', async () => { + // Sign with a different secret — jwtVerify will fail. + const wrongApp = Fastify({ logger: false }); + await wrongApp.register(jwtPlugin as any, { secret: 'totally-different-secret-xxxxx' }); + const badToken = signToken(wrongApp, { id: USER_ID }); + await wrongApp.close(); + + const res = await app.inject({ + method: 'GET', + url: '/protected', + headers: bearerHeader(badToken), + }); + + expect(res.statusCode).toBe(401); + expect(res.json().error).toBe('Unauthorized'); + }); + + it('200 — allows authenticated request when JWT is sent via cookie', async () => { + mockRedis.exists.mockResolvedValue(0); + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + + const res = await app.inject({ + method: 'GET', + url: '/protected', + headers: { Cookie: `access_Token=${token}` }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ ok: true }); + // Blocklist check must still run — the key is derived from the cookie token. + expect(mockRedis.exists).toHaveBeenCalledOnce(); + expect(mockRedis.exists.mock.calls[0][0]).toBe(blocklistKey(token)); + }); + + it('logs a warning when the Redis check throws and still allows valid JWT through', async () => { + const warnMock = vi.fn(); + (app.log as any).warn = warnMock; + mockRedis.exists.mockRejectedValueOnce(new Error('ECONNREFUSED')); + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + + const res = await app.inject({ + method: 'GET', + url: '/protected', + headers: bearerHeader(token), + }); + + expect(res.statusCode).toBe(200); + expect(warnMock).toHaveBeenCalledOnce(); + const [obj, message] = warnMock.mock.calls[0] as [{ err: Error }, string]; + expect(message).toMatch(/blocklist check failed/i); + expect(obj.err).toBeInstanceOf(Error); + }); + + it('401 — rejects "Authorization: Bearer " with no token value after the prefix', async () => { + const res = await app.inject({ + method: 'GET', + url: '/protected', + headers: { Authorization: 'Bearer ' }, + }); + + // extractRawJwt returns '' (falsy) — blocklist check is skipped, + // jwtVerify receives an empty token and throws. + expect(res.statusCode).toBe(401); + expect(mockRedis.exists).not.toHaveBeenCalled(); + }); +}); + +// ─── Revocation flow — end-to-end ──────────────────────────────────────────── +// +// Verifies the full lifecycle: token works → logout blocklists it → +// authenticate rejects it. This is the critical security invariant. + +describe('revocation flow — end-to-end', () => { + let app: FastifyInstance; + let mockRedis: MockRedis; + + beforeEach(async () => { + vi.clearAllMocks(); + mockRedis = createMockRedis(); + app = await buildTestApp(mockRedis); + }); + + afterEach(async () => { + await app.close(); + }); + + it('token is usable before logout and rejected after blocklisting', async () => { + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + + // Step 1: token is valid — protected route responds 200. + const before = await app.inject({ + method: 'GET', + url: '/protected', + headers: bearerHeader(token), + }); + expect(before.statusCode).toBe(200); + + // Step 2: logout succeeds and writes the key to the blocklist. + const logout = await app.inject({ + method: 'DELETE', + url: '/auth/logout', + headers: bearerHeader(token), + }); + expect(logout.statusCode).toBe(200); + expect(mockRedis.set).toHaveBeenCalledOnce(); + + // Step 3: simulate Redis now returning 1 for this token's blocklist key. + // (In production this is automatic — the SET from step 2 persists in Redis.) + mockRedis.exists.mockResolvedValueOnce(1); + + // Step 4: same token is now rejected by the authenticate middleware. + const after = await app.inject({ + method: 'GET', + url: '/protected', + headers: bearerHeader(token), + }); + expect(after.statusCode).toBe(401); + expect(after.json().error).toBe('Token has been revoked'); + }); + + it('cookie-delivered token is also rejected after logout', async () => { + const token = signToken(app, { id: USER_ID, username: USERNAME }, { expiresIn: '30d' }); + + // Logout via cookie — browser clients never send an Authorization header. + const logout = await app.inject({ + method: 'DELETE', + url: '/auth/logout', + headers: { Cookie: `access_Token=${token}` }, + }); + expect(logout.statusCode).toBe(200); + expect(mockRedis.set).toHaveBeenCalledOnce(); + // The blocklist key must match the token delivered via cookie. + const [writtenKey] = mockRedis.set.mock.calls[0] as unknown as [string]; + expect(writtenKey).toBe(blocklistKey(token)); + + // Simulate blocklist hit on next request. + mockRedis.exists.mockResolvedValueOnce(1); + + const after = await app.inject({ + method: 'GET', + url: '/protected', + headers: { Cookie: `access_Token=${token}` }, + }); + expect(after.statusCode).toBe(401); + expect(after.json().error).toBe('Token has been revoked'); + }); +}); + +// ─── blocklistKey utility ───────────────────────────────────────────────────── + +describe('blocklistKey', () => { + it('produces a consistent key for the same token', () => { + const token = 'header.payload.signature'; + expect(blocklistKey(token)).toBe(blocklistKey(token)); + }); + + it('produces different keys for different signatures', () => { + expect(blocklistKey('h.p.sig1')).not.toBe(blocklistKey('h.p.sig2')); + }); + + it('always starts with "blocklist:" followed by 64 hex chars', () => { + const key = blocklistKey('h.p.anysignature'); + expect(key).toMatch(/^blocklist:[0-9a-f]{64}$/); + }); + + it('produces the same key regardless of header or payload content', () => { + // Two tokens with different claims but the same signature produce the same key. + // (Unlikely in practice, but documents the hash-of-signature contract.) + const key1 = blocklistKey('differentHeader.differentPayload.SAME_SIG'); + const key2 = blocklistKey('anotherHeader.anotherPayload.SAME_SIG'); + expect(key1).toBe(key2); + }); +}); + +// ─── extractRawJwt utility ──────────────────────────────────────────────────── + +describe('extractRawJwt', () => { + function makeRequest(overrides: Partial<{ authorization: string; cookies: Record }>): FastifyRequest { + return { + headers: { authorization: overrides.authorization }, + cookies: overrides.cookies ?? {}, + } as any; + } + + it('returns token from Authorization: Bearer header', () => { + const req = makeRequest({ authorization: 'Bearer my.jwt.token' }); + expect(extractRawJwt(req)).toBe('my.jwt.token'); + }); + + it('returns token from cookie when no Authorization header', () => { + const req = makeRequest({ cookies: { access_Token: 'cookie.jwt.token' } }); + expect(extractRawJwt(req)).toBe('cookie.jwt.token'); + }); + + it('prefers Authorization header over cookie', () => { + const req = makeRequest({ + authorization: 'Bearer header.jwt.token', + cookies: { access_Token: 'cookie.jwt.token' }, + }); + expect(extractRawJwt(req)).toBe('header.jwt.token'); + }); + + it('returns null when neither header nor cookie is present', () => { + const req = makeRequest({}); + expect(extractRawJwt(req)).toBeNull(); + }); + + it('returns null when Authorization header is not Bearer', () => { + const req = makeRequest({ authorization: 'Basic dXNlcjpwYXNz' }); + expect(extractRawJwt(req)).toBeNull(); + }); + + it('returns null when Authorization is "Bearer " with no token after the space', () => { + const req = makeRequest({ authorization: 'Bearer ' }); + // slice(7) || null normalises the empty string to null. + expect(extractRawJwt(req)).toBeNull(); + }); + + it('returns null when the token cookie value is empty', () => { + const req = makeRequest({ cookies: { access_Token: '' } }); + // || null normalises the empty string to null, matching the return type. + expect(extractRawJwt(req)).toBeNull(); + }); +}); diff --git a/apps/backend/src/__tests__/profiles.test.ts b/apps/backend/src/__tests__/profiles.test.ts index 07d10f98..0633b841 100644 --- a/apps/backend/src/__tests__/profiles.test.ts +++ b/apps/backend/src/__tests__/profiles.test.ts @@ -1,6 +1,8 @@ +import Fastify, { type FastifyInstance } from 'fastify'; import { describe, it, expect, beforeEach, vi } from 'vitest'; -import Fastify from 'fastify'; + import { profileRoutes } from '../routes/profiles.js'; + import type { PrismaClient } from '@prisma/client'; const mockUser = { @@ -20,15 +22,15 @@ const mockUser = { providerId: 'gh-123', }; -const mockPrisma: Pick = { +const mockPrisma = { user: { findUnique: vi.fn(), findFirst: vi.fn(), update: vi.fn(), - } as unknown as PrismaClient['user'], + }, }; -async function buildApp() { +async function buildApp():Promise { const app = Fastify(); app.decorate('prisma', mockPrisma as unknown as PrismaClient); app.decorate('authenticate', async (request: any) => { diff --git a/apps/backend/src/__tests__/validateEnv.test.ts b/apps/backend/src/__tests__/validateEnv.test.ts index eb0574bd..34fce500 100644 --- a/apps/backend/src/__tests__/validateEnv.test.ts +++ b/apps/backend/src/__tests__/validateEnv.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; + import { validateEnv } from '../utils/validateEnv.js'; // ── helpers ────────────────────────────────────────────────────────────────── @@ -8,8 +9,8 @@ import { validateEnv } from '../utils/validateEnv.js'; * that a failing validateEnv() call does not terminate the test process. * Returns the spy so callers can assert the exit code. */ -function stubExit() { - return vi.spyOn(process, 'exit').mockImplementation((code?: number | string) => { +function stubExit(): ReturnType { + return vi.spyOn(process, 'exit').mockImplementation((code?: number | string | null) => { throw new Error(`process.exit(${code})`); }) as unknown as ReturnType; } diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 06b87205..44842088 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -7,8 +7,7 @@ import helmet from '@fastify/helmet'; import jwt from '@fastify/jwt'; import multipart from '@fastify/multipart'; import rateLimit from '@fastify/rate-limit'; -import fastifyStatic from '@fastify/static'; -import Fastify, {type FastifyInstance} from 'fastify'; +import Fastify, {type FastifyInstance, type FastifyReply, type FastifyRequest} from 'fastify'; import { prismaPlugin } from './plugins/prisma.js'; import { redisPlugin } from './plugins/redis.js'; @@ -21,8 +20,11 @@ import { followRoutes } from './routes/follow.js'; import { nfcRoutes } from './routes/nfc.js'; import { profileRoutes } from './routes/profiles.js'; import { publicRoutes } from './routes/public.js'; -import { validateEnv } from './utils/validateEnv.js'; import { teamRoutes } from './routes/team.js'; +import { extractRawJwt, blocklistKey } from './utils/jwt.js'; +import { validateEnv } from './utils/validateEnv.js'; + +import type { AuthenticatedUser } from './types/fastify.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -42,6 +44,12 @@ export async function buildApp():Promise { }, }); + // Log method + path for every incoming request. + app.addHook('onRequest', (request, _reply, done) => { + app.log.info({ method: request.method, url: request.url }, 'incoming request'); + done(); + }); + // ─── Core Plugins ─── await app.register(cors, { origin: process.env.PUBLIC_APP_URL || 'http://localhost:5173', @@ -65,12 +73,19 @@ export async function buildApp():Promise { }, }); + // cookie must be registered before jwt so that @fastify/jwt can read the + // `token` cookie during jwtVerify() for browser-based clients. + await app.register(cookie); + await app.register(jwt, { // validateEnv() above guarantees JWT_SECRET is present and safe. secret: process.env.JWT_SECRET!, + cookie: { + // Matches the cookie name set in the OAuth callback handlers. + cookieName: 'token', + signed: false, + }, }); - - await app.register(cookie); await app.register(multipart, { limits: { fileSize: 5 * 1024 * 1024 } }); // 5MB await app.register(rateLimit, { max: 100, @@ -88,13 +103,31 @@ export async function buildApp():Promise { await app.register(redisPlugin); } // ─── Auth Decorator ─── - app.decorate('authenticate', async function (request: any, reply: any) { + // Checks the Redis blocklist before calling jwtVerify so that a logged-out + // token is rejected immediately even if it has not yet expired. + // The blocklist check is skipped when Redis is not registered (test env). + app.decorate('authenticate', async function (request: FastifyRequest, reply: FastifyReply) { try { - // Ensure the verified payload is assigned to `request.user` like the original plugin. - const payload = await request.jwtVerify(); - if (payload) request.user = payload; - } catch (error) { - reply.status(401).send({ error: 'Unauthorized' }); + if (app.hasDecorator('redis')) { + const raw = extractRawJwt(request); + if (raw) { + try { + const revoked = await app.redis.exists(blocklistKey(raw)); + if (revoked) { + return reply.status(401).send({ error: 'Token has been revoked' }); + } + } catch (redisErr) { + // Redis is unavailable — fail open to avoid an outage on every + // authenticated request. The JWT expiry is still the safety net. + app.log.warn({ err: redisErr }, 'Redis blocklist check failed — proceeding with JWT verification'); + } + } + } + // Assign verified payload to request.user (upstream addition). + const payload = await request.jwtVerify(); + if (payload) { request.user = payload; } + } catch (_err) { + return reply.status(401).send({ error: 'Unauthorized' }); } }); diff --git a/apps/backend/src/env.ts b/apps/backend/src/env.ts index 7d841d9c..ceb9222d 100644 --- a/apps/backend/src/env.ts +++ b/apps/backend/src/env.ts @@ -8,9 +8,10 @@ const envPath = path.resolve(__dirname, '../../../.env'); const result = dotenv.config({ path: envPath }); if (result.error) { - // Keep failing fast but avoid leaking via console in production code paths. - // This file runs before the Fastify logger is available; throw so the process exits. - throw result.error; -} else { - // .env loaded successfully + if (process.env.NODE_ENV === 'production') { + // In production, env vars come from Kubernetes secrets — .env file is not expected. + } else { + // In development, .env is required. Fail fast. + throw result.error; + } } diff --git a/apps/backend/src/routes/analytics.ts b/apps/backend/src/routes/analytics.ts index a975424f..884c0528 100644 --- a/apps/backend/src/routes/analytics.ts +++ b/apps/backend/src/routes/analytics.ts @@ -1,3 +1,4 @@ +import type { Prisma } from '@prisma/client'; import type { FastifyInstance, FastifyRequest, @@ -12,14 +13,14 @@ export async function analyticsRoutes( '/overview', { // eslint-disable-next-line @typescript-eslint/unbound-method - preHandler: [async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } }], + preHandler: [app.authenticate], }, async ( request: FastifyRequest, _reply: FastifyReply ) => { - const userId = (request.user as any).id; - const username = (request.user as any).username; + const userId = request.user.id; + const username = request.user.username; const today = new Date(); today.setHours(0, 0, 0, 0); @@ -70,13 +71,14 @@ export async function analyticsRoutes( // Count unique viewers // In raw SQL this is `SELECT COUNT(DISTINCT viewer_id) FROM card_views WHERE owner_id = ?` // Prisma group-by as workaround: - const uniqueViewersQuery = - await app.prisma.cardView.groupBy({ - by: ['viewerId', 'viewerIp'], - where: { ownerId: userId }, - }); + const uniqueViewersQuery = await app.prisma.$queryRaw<[{ count: bigint }]>` + SELECT COUNT(DISTINCT viewer_id) AS count + FROM card_views + WHERE owner_id = ${userId} + AND viewer_id IS NOT NULL + `; - const uniqueViewers = uniqueViewersQuery.length; + const uniqueViewers = Number(uniqueViewersQuery[0]?.count ?? 0); return { totalViews, @@ -97,7 +99,7 @@ export async function analyticsRoutes( '/views', { // eslint-disable-next-line @typescript-eslint/unbound-method - preHandler: [async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } }], + preHandler: [app.authenticate], }, async ( request: FastifyRequest<{ @@ -108,12 +110,12 @@ export async function analyticsRoutes( }>, _reply: FastifyReply ) => { - const userId = (request.user as any).id; + const userId = request.user.id; const page = parseInt(request.query.page || '1', 10); const limit = 20; const skip = (page - 1) * limit; - const whereClause: any = { ownerId: userId }; + const whereClause: Prisma.CardViewWhereInput = { ownerId: userId }; if (request.query.cardId) { whereClause.cardId = request.query.cardId; diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index c14949e1..81cce6a9 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -1,6 +1,18 @@ +import { handleDbError, isGitHubTokenError, isGoogleTokenError } from '../utils/error.util.js'; +import { extractRawJwt, blocklistKey, signAccessToken } from '../utils/jwt.js'; +import { buildOAuthState, getMobileRedirectUri } from '../utils/oauth.js'; +import { generateRefreshToken, hashIp, hashRefreshToken } from '../utils/refreshToken.js'; +import { oAuthCallbackSchema, oAuthStartSchema } from '../validations/auth.validation.js'; + +import type { GitHubTokenErrorResponse, GitHubTokenResponse } from '../utils/error.util.js'; +import type { OAuthCallbackQuery, OAuthStartQuery } from '../validations/auth.validation.js'; import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; -import { encrypt } from '../utils/encryption.js'; -import { buildOAuthState, getMobileRedirectUri } from '../services/authService.js'; + +interface GitHubEmailResponse { + email: string; + primary: boolean; + verified: boolean; +} const GITHUB_AUTH_URL = 'https://github.com/login/oauth/authorize'; const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token'; @@ -9,12 +21,30 @@ const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'; const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'; const GOOGLE_USER_URL = 'https://www.googleapis.com/oauth2/v2/userinfo'; -interface OAuthCallbackQuery { - code: string; - state?: string; +interface GoogleUser { + id: string; + email: string; + name: string; + picture?: string; +} + +interface GoogleTokenResponse { + access_token: string; + refresh_token?: string; + expires_in: number; + token_type: string; } -export async function authRoutes(app: FastifyInstance) { +interface GitHubUserResponse { + id: number; + login: string; + name: string | null; + email: string | null; + avatar_url: string; +} + + +export async function authRoutes(app: FastifyInstance): Promise { // Developer login bypass (development only) if (process.env.NODE_ENV !== 'production') { app.post('/dev-login', async (request: FastifyRequest, reply: FastifyReply) => { @@ -28,10 +58,18 @@ export async function authRoutes(app: FastifyInstance) { } // GitHub OAuth start - app.get('/github', async (request: FastifyRequest, reply: FastifyReply) => { + app.get('/github', async (request: FastifyRequest<{Querystring: OAuthStartQuery}>, reply: FastifyReply) => { + const clientId = process.env.GITHUB_CLIENT_ID; + if(!clientId){ + return reply.status(400).send() + } const redirectUri = `${process.env.BACKEND_URL}/auth/github/callback`; - const clientState = (request.query as any).state || ''; - const mobileRedirectUri = (request.query as any).mobile_redirect_uri || ''; + + const parsed = oAuthStartSchema.safeParse(request.query); + if (!parsed.success) { + return reply.status(400).send({ error: parsed.error.errors[0].message }); + } + const { state: clientState, mobile_redirect_uri: mobileRedirectUri } = parsed.data; const state = buildOAuthState(clientState, mobileRedirectUri); reply.setCookie('oauth_state', state, { @@ -43,7 +81,7 @@ export async function authRoutes(app: FastifyInstance) { }); const params = new URLSearchParams({ - client_id: (process.env.GITHUB_CLIENT_ID || '').trim(), + client_id: clientId, redirect_uri: redirectUri, scope: 'read:user user:email', state, @@ -56,17 +94,19 @@ export async function authRoutes(app: FastifyInstance) { // GitHub OAuth callback app.get('/github/callback', async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => { - const { code, state } = request.query; const storedState = request.cookies?.oauth_state; - if (!state || !storedState || state !== storedState) { + const parsed = oAuthCallbackSchema.safeParse(request.query); + if (!parsed.success) { + reply.clearCookie('oauth_state', { path: '/' }); + return reply.status(400).send({ error: 'Invalid callback parameters' }); + } + const { code, state } = parsed.data; + if (!storedState || state !== storedState) { + reply.clearCookie('oauth_state', { path: '/' }); return reply.status(400).send({ error: 'Invalid or missing OAuth state — possible CSRF attack' }); } reply.clearCookie('oauth_state', { path: '/' }); - if (!code) { - return reply.status(400).send({ error: 'Missing authorization code' }); - } - try { const tokenRes = await fetch(GITHUB_TOKEN_URL, { method: 'POST', @@ -79,70 +119,155 @@ export async function authRoutes(app: FastifyInstance) { }), }); - const tokenData = (await tokenRes.json()) as any; - if (tokenData.error) { - app.log.error({ tokenData }, 'GitHub token error'); - return reply.status(400).send({ error: 'Failed to authenticate with GitHub' }); - } + const tokenData = (await tokenRes.json()) as + GitHubTokenResponse | GitHubTokenErrorResponse; + + if (!tokenRes.ok || isGitHubTokenError(tokenData)) { + app.log.error( + { tokenData, status: tokenRes.status }, + 'GitHub token exchange failed', + ); + + return reply.status(400).send({ + error: 'Failed to authenticate with GitHub', + }); + } const userRes = await fetch(GITHUB_USER_URL, { headers: { Authorization: `Bearer ${tokenData.access_token}` } }); - const githubUser = (await userRes.json()) as any; + const githubUser = (await userRes.json()) as GitHubUserResponse;; let email = githubUser.email; if (!email) { const emailsRes = await fetch('https://api.github.com/user/emails', { headers: { Authorization: `Bearer ${tokenData.access_token}` }, }); - const emails = (await emailsRes.json()) as any[]; - const primary = emails.find((e: any) => e.primary && e.verified); - email = primary?.email || emails[0]?.email; - } + const emails = (await emailsRes.json()) as GitHubEmailResponse[]; + const primary = emails.find( + (e) => e.primary && e.verified, + ); - const user = await app.prisma.user.upsert({ - where: { provider_providerId: { provider: 'github', providerId: String(githubUser.id) } }, - update: { - email: email || `${githubUser.login}@github.local`, - displayName: githubUser.name || githubUser.login, - avatarUrl: githubUser.avatar_url, - }, - create: { - email: email || `${githubUser.login}@github.local`, - username: githubUser.login, - displayName: githubUser.name || githubUser.login, - bio: githubUser.bio, - company: githubUser.company, - avatarUrl: githubUser.avatar_url, - provider: 'github', - providerId: String(githubUser.id), - }, - }); + email = primary?.email ?? null; + } - try { - const encryptedToken = encrypt(tokenData.access_token); - await app.prisma.oAuthToken.upsert({ - where: { userId_platform: { userId: user.id, platform: 'github' } }, - update: { accessToken: encryptedToken, scopes: 'read:user user:email' }, - create: { userId: user.id, platform: 'github', accessToken: encryptedToken, scopes: 'read:user user:email' }, + if (!email) { + return reply.status(400).send({ + error: 'No email returned by GitHub', }); - } catch (err) { - app.log.error({ err, userId: user.id }, 'Failed to persist GitHub OAuth token — authentication proceeds'); } - const token = app.jwt.sign({ id: user.id, username: user.username }, { expiresIn: '30d' }); + const baseUsername = email.split('@')[0].replace(/[^a-zA-Z0-9_-]/g, ''); + + const identity = await app.prisma.userIdentity.findUnique({ + where: { + provider_providerId: { + provider: 'github', + providerId: githubUser.id.toString() + }, + }, + include: { + user: true + } + }) + + let user; + + if (identity) { + user = await app.prisma.user.update({ + where: { + id: identity.user.id, + }, + data: { + email, + displayName: githubUser.name || baseUsername, + avatarUrl: githubUser.avatar_url, + lastSignInAt: new Date(), + isActive: true + }, + }); + }else{ + + const existingAccount = await app.prisma.user.findUnique({ + where: { + email + } + }) + + if(existingAccount){ + await app.prisma.userIdentity.create({ + data: { + userId: existingAccount.id, + provider: 'github', + providerId: githubUser.id.toString() + } + }) + user = existingAccount; + }else{ + user = await app.prisma.user.create({ + data: { + email, + username: `${baseUsername}_${Date.now().toString(36)}`, + displayName: githubUser.name || baseUsername, + avatarUrl: githubUser.avatar_url, + emailVerified: true, + isActive: true, + lastSignInAt: new Date(), + + identities: { + create: { + provider: 'github', + providerId: githubUser.id.toString() + } + } + } + }) + } + } + + const accessToken = signAccessToken(app, user) + const refreshToken = generateRefreshToken() + const refreshTokenHash = hashRefreshToken(refreshToken); + const ip = hashIp(request.ip) + const userAgent = request.headers['user-agent'] ?? 'unknown'; + + await app.prisma.refreshToken.create({ + data: { + userId: user.id, + tokenHash: refreshTokenHash, + family: crypto.randomUUID(), + expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), + ip, + userAgent + } + }) if (request.query.state?.startsWith('mobile_')) { - const mobileRedirect = getMobileRedirectUri(request.query.state) || process.env.MOBILE_REDIRECT_URI; - return reply.redirect(`${mobileRedirect}#token=${token}`); + const exchangeCode = crypto.randomUUID(); + await app.redis.set( + `mobile_exchange:${exchangeCode}`, + JSON.stringify({ accessToken, refreshToken }), + 'EX', 60 + ); + const mobileRedirect = getMobileRedirectUri(request.query.state) + || process.env.MOBILE_REDIRECT_URI; + return reply.redirect(`${mobileRedirect}?code=${exchangeCode}`); } - reply.setCookie('token', token, { + reply.setCookie('access_Token', accessToken,{ httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', path: '/', - maxAge: 30 * 24 * 60 * 60, + maxAge: 15 * 60, }); + reply.setCookie('refresh_token', refreshToken,{ + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 90 * 24 * 60 * 60, + }, + ); return reply.redirect(`${process.env.PUBLIC_APP_URL}/dashboard`); } catch (error) { app.log.error({ error }, 'GitHub auth error'); @@ -151,12 +276,20 @@ export async function authRoutes(app: FastifyInstance) { }); // Google OAuth start - app.get('/google', async (request: FastifyRequest, reply: FastifyReply) => { + app.get('/google', async (request: FastifyRequest<{Querystring: OAuthStartQuery}>, reply: FastifyReply) => { + const clientId = process.env.GOOGLE_CLIENT_ID; + if(!clientId){ + return reply.status(400).send() + } const redirectUri = `${process.env.BACKEND_URL}/auth/google/callback`; - const clientState = (request.query as any).state || ''; - const mobileRedirectUri = (request.query as any).mobile_redirect_uri || ''; + + const parsed = oAuthStartSchema.safeParse(request.query); + if (!parsed.success) { + return reply.status(400).send({ error: parsed.error.errors[0].message }); + } + const { state: clientState, mobile_redirect_uri: mobileRedirectUri } = parsed.data; const state = buildOAuthState(clientState, mobileRedirectUri); - + reply.setCookie('oauth_state', state, { httpOnly: true, secure: process.env.NODE_ENV === 'production', @@ -164,9 +297,8 @@ export async function authRoutes(app: FastifyInstance) { path: '/', maxAge: 10 * 60, }); - const params = new URLSearchParams({ - client_id: (process.env.GOOGLE_CLIENT_ID || '').trim(), + client_id: clientId, redirect_uri: redirectUri, response_type: 'code', scope: 'openid email profile', @@ -181,17 +313,19 @@ export async function authRoutes(app: FastifyInstance) { // Google callback app.get('/google/callback', async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => { - const { code, state } = request.query; - const storedState = request.cookies?.oauth_state; - if (!state || !storedState || state !== storedState) { - return reply.status(400).send({ error: 'Invalid or missing OAuth state — possible CSRF attack' }); + const parsed = oAuthCallbackSchema.safeParse(request.query); + if (!parsed.success) { + reply.clearCookie('oauth_state', { path: '/' }); + return reply.status(400).send({ error: 'Invalid callback parameters' }); } - reply.clearCookie('oauth_state', { path: '/' }); + const { code, state } = parsed.data; - if (!code) { - return reply.status(400).send({ error: 'Missing authorization code' }); + if (!storedState || state !== storedState) { + reply.clearCookie('oauth_state', { path: '/' }); + return reply.status(400).send({ error: 'Invalid or missing OAuth state — possible CSRF attack' }); } + reply.clearCookie('oauth_state', { path: '/' }); try { const tokenRes = await fetch(GOOGLE_TOKEN_URL, { @@ -206,60 +340,263 @@ export async function authRoutes(app: FastifyInstance) { }), }); - const tokenData = (await tokenRes.json()) as any; - if (tokenData.error) { - app.log.error({ tokenData }, 'Google token error'); + const tokenData = (await tokenRes.json()) as GoogleTokenResponse + if (!tokenRes.ok || isGoogleTokenError(tokenData)) { + app.log.error({ tokenData, status: tokenRes.status }, 'Google token exchange failed'); return reply.status(400).send({ error: 'Failed to authenticate with Google' }); } const userRes = await fetch(GOOGLE_USER_URL, { headers: { Authorization: `Bearer ${tokenData.access_token}` } }); - const googleUser = (await userRes.json()) as any; + const googleUser = (await userRes.json()) as GoogleUser; const baseUsername = googleUser.email.split('@')[0].replace(/[^a-zA-Z0-9_-]/g, ''); - const user = await app.prisma.user.upsert({ - where: { provider_providerId: { provider: 'google', providerId: googleUser.id } }, - update: { email: googleUser.email, displayName: googleUser.name || baseUsername, avatarUrl: googleUser.picture }, - create: { - email: googleUser.email, - username: `${baseUsername}_${Date.now().toString(36)}`, - displayName: googleUser.name || baseUsername, - avatarUrl: googleUser.picture, - provider: 'google', - providerId: googleUser.id, + const identity = await app.prisma.userIdentity.findUnique({ + where: { + provider_providerId: { + provider: 'google', + providerId: googleUser.id + }, }, - }); - - const token = app.jwt.sign({ id: user.id, username: user.username }, { expiresIn: '30d' }); + include: { + user: true + } + }) + + let user; + + if (identity) { + user = await app.prisma.user.update({ + where: { + id: identity.user.id, + }, + data: { + email: googleUser.email, + displayName: googleUser.name || baseUsername, + avatarUrl: googleUser.picture, + lastSignInAt: new Date(), + isActive: true + }, + }); + }else{ + const existingAccount = await app.prisma.user.findUnique({ + where: { + email: googleUser.email + } + }) + + if(existingAccount){ + await app.prisma.userIdentity.create({ + data: { + userId: existingAccount.id, + provider: 'google', + providerId: googleUser.id + } + }) + + user = existingAccount + }else{ + user = await app.prisma.user.create({ + data: { + email: googleUser.email, + username: `${baseUsername}_${Date.now().toString(36)}`, + displayName: googleUser.name || baseUsername, + avatarUrl: googleUser.picture, + emailVerified: true, + isActive: true, + lastSignInAt: new Date(), + + identities: { + create: { + provider: 'google', + providerId: googleUser.id + } + } + } + }) + + } + } + + const accessToken = signAccessToken(app, user) + const refreshToken = generateRefreshToken() + const refreshTokenHash = hashRefreshToken(refreshToken); + const ip = hashIp(request.ip) + const userAgent = request.headers['user-agent'] ?? 'unknown'; + + await app.prisma.refreshToken.create({ + data: { + userId: user.id, + tokenHash: refreshTokenHash, + family: crypto.randomUUID(), + expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), + ip, + userAgent + } + }) if (request.query.state?.startsWith('mobile_')) { - const mobileRedirect = getMobileRedirectUri(request.query.state) || process.env.MOBILE_REDIRECT_URI; - return reply.redirect(`${mobileRedirect}#token=${token}`); + const exchangeCode = crypto.randomUUID(); + await app.redis.set( + `mobile_exchange:${exchangeCode}`, + JSON.stringify({ accessToken, refreshToken }), + 'EX', 60 + ); + const mobileRedirect = getMobileRedirectUri(request.query.state) + || process.env.MOBILE_REDIRECT_URI; + return reply.redirect(`${mobileRedirect}?code=${exchangeCode}`); } - reply.setCookie('token', token, { + reply.setCookie('access_Token', accessToken,{ httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', path: '/', - maxAge: 30 * 24 * 60 * 60, + maxAge: 15 * 60, }); + reply.setCookie('refresh_token', refreshToken,{ + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 90 * 24 * 60 * 60, + }, + ); + + app.log.info({ + user: user.id, + provider: 'google' + }, 'User is authenticated'); + return reply.redirect(`${process.env.PUBLIC_APP_URL}/dashboard`); } catch (error) { + handleDbError(error, request, reply) app.log.error({ error }, 'Google auth error'); return reply.status(500).send({ error: 'Authentication failed' }); } }); + app.post('/refresh', async(request: FastifyRequest, reply: FastifyReply) => { + const refreshToken = request.cookies.refresh_token ?? (request.body as { refresh_token?: string })?.refresh_token; + + if (!refreshToken) { + return reply.status(401).send({ + error: 'Refresh token missing', + }); + } + const tokenHash = hashRefreshToken(refreshToken); + + try { + + const storedToken = await app.prisma.refreshToken.findUnique({ + where: { + tokenHash + }, + include: { + user: true + } + }) + + if (!storedToken) { + return reply.status(401).send({ + error: 'Invalid refresh token', + }); + } + + if (storedToken.revokedAt) { + return reply.status(401).send({ + error: 'Refresh token revoked', + }); + } + + if(storedToken.expiresAt < new Date()){ + return reply.status(401).send({ + error: 'Refresh token expired', + }); + } + + await app.prisma.refreshToken.update({ + where: { + id: storedToken.id, + }, + data: { + revokedAt: new Date(), + }, + }); + + const newRefreshToken = generateRefreshToken(); + const newTokenHash = hashRefreshToken(newRefreshToken); + const ip = hashIp(request.ip) + const userAgent = request.headers['user-agent'] ?? 'unknown'; + + const details = { + id: storedToken.user.id, + username: storedToken.user.username + } + + await app.prisma.refreshToken.create({ + data: { + userId: storedToken.user.id, + tokenHash: newTokenHash, + family: storedToken.family, + expiresAt: new Date( + Date.now() + 90 * 24 * 60 * 60 * 1000, + ), + userAgent, + ip, + }, + }); + + + const accessToken = signAccessToken(app,details) + + const isMobileRequest = !request.cookies.refresh_token; + if (isMobileRequest) { + return reply.status(200).send({ accessToken, refreshToken: newRefreshToken }); + } + + reply.setCookie('access_Token', accessToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 15 * 60, + }); + + reply.setCookie('refresh_token',newRefreshToken,{ + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 90 * 24 * 60 * 60, + }, + ); + + return reply.status(200).send('Token revoked') + + } catch (error) { + handleDbError(error, request, reply) + app.log.error(error) + } + + }) + + app.post('/mobile/exchange', async (request: FastifyRequest<{Body: {code: string}}>, reply: FastifyReply) => { + const { code } = request.body; + const raw = await app.redis.getdel(`mobile_exchange:${code}`); + if (!raw) {return reply.status(400).send({ error: 'Invalid or expired exchange code' });} + + const { accessToken, refreshToken } = JSON.parse(raw); + return { accessToken, refreshToken }; + }); + // Current user - app.get('/me', { preHandler: [async (request, reply) => { - const server = request.server as any; - if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } - if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } - try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } - }] }, async (request: FastifyRequest, reply: FastifyReply) => { - const userId = (request.user as any).id; + app.get('/me', { + // eslint-disable-next-line @typescript-eslint/unbound-method + preHandler: [app.authenticate], + }, async (request: FastifyRequest, reply: FastifyReply) => { + const userId = request.user.id; const user = await app.prisma.user.findUnique({ where: { id: userId }, select: { @@ -286,8 +623,70 @@ export async function authRoutes(app: FastifyInstance) { return { ...userData, connectedPlatforms: oauthTokens }; }); - app.post('/logout', async (request: FastifyRequest, reply: FastifyReply) => { - reply.clearCookie('token', { path: '/' }); - return { message: 'Logged out' }; + // Legacy endpoint kept for backward compatibility with existing clients. + // Cookie-only logout — use DELETE /auth/logout for token revocation. + app.post('/logout', async (_request: FastifyRequest, reply: FastifyReply) => { + app.log.info('Legacy cookie-only logout called — token not blocklisted'); + reply.clearCookie('access_Token', { path: '/' }); + return reply.status(200).send({message: 'Logged out',}); + }); + + // ─── Secure Logout — blocklists the token in Redis ─── + // + // Requires a valid JWT so that only the token's owner can revoke it. + // The token signature is hashed and stored in Redis with a TTL equal to the + // token's remaining lifetime, so the entry self-cleans when the JWT expires. + // + // Tradeoff: if Redis is down the block write is skipped (non-fatal), but the + // token will still expire naturally based on its exp claim. + + app.delete('/logout', { + // eslint-disable-next-line @typescript-eslint/unbound-method + preHandler: [app.authenticate], + }, async (request: FastifyRequest, reply: FastifyReply) => { + const raw = extractRawJwt(request); + + if (raw && app.hasDecorator('redis')) { + // jwt.decode() skips signature verification — safe here because the + // authenticate preHandler above already called jwtVerify() successfully. + const payload = app.jwt.decode<{ exp?: number }>(raw); + const exp = payload?.exp; + + if (exp) { + const ttl = exp - Math.floor(Date.now() / 1000); + if (ttl > 0) { + try { + await app.redis.set(blocklistKey(raw), '1', 'EX', ttl); + } catch (err) { + // Non-fatal: log and continue. The token will expire on its own. + app.log.warn({ err, userId: request.user?.id }, 'Redis blocklist write failed during logout — token will expire naturally'); + } + } + } else { + // A JWT without exp cannot be given a finite Redis TTL, so it cannot be + // actively revoked. This should never happen with tokens signed by this + // server (we always pass expiresIn), but log a warning so it is + // visible if a custom or third-party token ever reaches this path. + app.log.warn( + 'JWT missing exp claim — skipping Redis blocklist; token cannot be actively revoked', + ); + } + } + + reply.clearCookie('access_Token', { path: '/' }); + reply.clearCookie('refresh_token', { path: '/' }); + + const refreshToken = request.cookies.refresh_token ?? (request.body as { refresh_token?: string })?.refresh_token; + if (refreshToken) { + const hash = hashRefreshToken(refreshToken); + await app.prisma.refreshToken.updateMany({ + where: { tokenHash: hash }, + data: { revokedAt: new Date() }, + }); + return reply.status(200).send({message: 'Logged out',}); + } + + return reply.status(200).send({ message: 'Logged out' }); }); } + diff --git a/apps/backend/src/routes/cards.ts b/apps/backend/src/routes/cards.ts index 32fe835c..3b2e0034 100644 --- a/apps/backend/src/routes/cards.ts +++ b/apps/backend/src/routes/cards.ts @@ -1,21 +1,20 @@ +import * as cardService from '../services/cardService.js' import { handleDbError } from '../utils/error.util.js'; -import { createCardSchema, updateCardSchema } from '../utils/validators.js'; -import * as cardService from '../services/cardService' +import { hashIp } from '../utils/refreshToken'; +import { createCardSchema ,updateCardSchema, addPlatformLinkSchema} from '../validations/card.validation'; -import type { Card } from '@devcard/shared'; -import type { Prisma } from '@prisma/client'; +import type { CardResponse, UpdateCardBody } from '../services/cardService'; +import type { Card } from '@devcard/shared/src/types.js'; +import type { CardVisibility } from '@prisma/client'; import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; - -interface CreateCardBody { +export interface CreateCardBody { title: string; linkIds: string[]; + description?: string; + visibility?: CardVisibility } -interface UpdateCardBody { - title?: string; - linkIds?: string[]; -} interface CardParams { id: string; @@ -39,7 +38,7 @@ interface CardLinkWithPlatform { platformLink: PlatformLink; } -interface CardWithLinks { +interface _CardWithLinks { id: string; userId: string; title: string; @@ -54,12 +53,11 @@ export async function cardRoutes(app: FastifyInstance): Promise { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } - try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } + try { await request.jwtVerify() } catch (_e) { reply.status(401).send({ error: 'Unauthorized' }) } }); // ─── List Cards ─── - - app.get('/', async (request: FastifyRequest, reply: FastifyReply): Promise => { + app.get('/', async (request: FastifyRequest, reply: FastifyReply): Promise => { const userId = (request.user as { id: string }).id; try { return await cardService.listCards(app, userId) @@ -68,8 +66,7 @@ export async function cardRoutes(app: FastifyInstance): Promise { } }); - // ─── Create Card ─── - + // ─── Creates Card ─── app.post('/', async (request: FastifyRequest<{ Body: CreateCardBody }>, reply: FastifyReply): Promise => { const userId = (request.user as { id: string }).id; const parsed = createCardSchema.safeParse(request.body); @@ -82,57 +79,240 @@ export async function cardRoutes(app: FastifyInstance): Promise { const card = await cardService.createCard(app, userId, parsed.data) return reply.status(201).send(card) } catch (error: any) { - if (error?.code === 'OWNERSHIP') return reply.status(403).send({ error: 'One or more links do not belong to your account' }) + if (error?.code === 'OWNERSHIP'){return reply.status(403).send({ error: 'One or more links do not belong to your account' })} return handleDbError(error, request, reply) } }); // ─── Update Card ─── - - app.put('/:id', async (request: FastifyRequest<{ Params: CardParams; Body: UpdateCardBody }>, reply: FastifyReply): Promise => { + app.put('/:id/update', async (request: FastifyRequest<{ Params: CardParams; Body: UpdateCardBody }>, reply: FastifyReply) => { const userId = (request.user as { id: string }).id; const { id } = request.params; try { const parsed = updateCardSchema.safeParse(request.body) - if (!parsed.success) return reply.status(400).send({ error: 'Validation failed', details: parsed.error.flatten() }) + if (!parsed.success) {return reply.status(400).send({ error: 'Validation failed', details: parsed.error.flatten() })} const updated = await cardService.updateCard(app, userId, id, parsed.data) - if (!updated) return reply.status(404).send({ error: 'Card not found' }) - return updated + return reply.status(200).send(updated) } catch (error: any) { - if (error?.code === 'OWNERSHIP') return reply.status(403).send({ error: 'One or more links do not belong to your account' }) - return handleDbError(error, request, reply) + if(error.code === 'NOT_FOUND'){ + return reply.status(404).send({ + error: error.message, + }); + } + app.log.error(error) + handleDbError(error,request,reply) } }); // ─── Delete Card ─── - - app.delete('/:id', async (request: FastifyRequest<{ Params: CardParams }>, reply: FastifyReply): Promise => { + app.delete('/:id/delete', async (request: FastifyRequest<{ Params: CardParams }>, reply: FastifyReply): Promise => { const userId = (request.user as { id: string }).id; const { id } = request.params; try { - const res = await cardService.deleteCard(app, userId, id) - if (res && (res as any).code === 'NOT_FOUND') return reply.status(404).send({ error: 'Card not found' }) - if (res && (res as any).code === 'LAST_CARD') return reply.status(400).send({ error: 'Cannot delete the last remaining card. A user must have at least one card.' }) + await cardService.deleteCard(app, userId, id) return reply.status(204).send() - } catch (error) { + } catch (error:any) { + if (error?.code === 'NOT_FOUND') { + return reply.status(404).send({ error: 'Card not found' }); + } + + if (error?.code === 'LAST_CARD') { + return reply.status(400).send({ + error: 'Cannot delete the last remaining card. A user must have at least one card.', + }); + } return handleDbError(error, request, reply) } }); // ─── Set Default Card ─── - app.put('/:id/default', async (request: FastifyRequest<{ Params: CardParams }>, reply: FastifyReply): Promise => { const userId = (request.user as { id: string }).id; const { id } = request.params; try { const resp = await cardService.setDefaultCard(app, userId, id) - if (!resp) return reply.status(404).send({ error: 'Card not found' }) - return resp - } catch (error) { + if (!resp) {return reply.status(404).send({ error: 'Card not found' })} + return reply.status(200).send('Default card updated') + } catch (error:any) { + if(error.code === 'NOT_FOUND'){ + return reply.status(404).send({ + error: error.message, + }); + } + app.log.error(error) return handleDbError(error, request, reply) } }); + + //Add platform-link + app.put('/:id/platform-link', async(request: FastifyRequest<{Params:{id: string}, Body: {platformLinkId: string}}>, reply: FastifyReply) => { + const cardId = request.params.id; + const userId = request.user.id; + const parsed = addPlatformLinkSchema.safeParse(request.body); + + if (!parsed.success) { + return reply.status(400).send({ error: 'Validation failed', details: parsed.error.flatten() }); + } + + try { + + const platformLinkId = parsed.data.platformLinkId + await cardService.addPlatFormLinks(app, userId, cardId, platformLinkId) + + return reply.status(200).send('Platform link added successfully') + + } catch (error: any) { + if (error?.code === 'CARD_NOT_FOUND') { + return reply.status(404).send({ + error: error.message, + }); + } + + if (error?.code === 'PLATFORM_LINK_NOT_FOUND') { + return reply.status(403).send({ + error: error.message, + }); + } + + if (error?.code === 'LINK_ALREADY_EXISTS') { + return reply.status(409).send({ + error: error.message, + }); + } + app.log.error(error) + handleDbError(error, request, reply) + } + }) + + //Share card + app.post('/:id/share',async(request: FastifyRequest<{Params: {id: string}}>, reply:FastifyReply) => { + const cardId = request.params.id; + const userId = request.user.id; + + try { + const link = await cardService.shareCard(app, userId, cardId); + return reply.status(200).send(link) + } catch (error:any) { + if (error?.code === 'CARD_NOT_FOUND') { + return reply.status(404).send({ + error: error.message, + }); + } + if (error?.code === 'CARD_PRIVATE') { + return reply.status(403).send({ + error: error.message, + }); + } + + app.log.error(error) + handleDbError(error, request, reply) + } + }) + + // TODO: + // Determine view source dynamically (url, qr, app, etc.). + // The shared card endpoint is currently used by multiple entry points, + // so source should not be hardcoded to "link". + //Get shared card + app.get('/share/:slug', async(request: FastifyRequest<{Params: {slug: string}}>, reply: FastifyReply) => { + const paramsSlug = request.params.slug; + const userId = request.user.id + const ip = hashIp(request.ip); + const userAgent = request.headers['user-agent'] ?? 'unknown'; + + + try { + const card = await cardService.getSharedCard(app, paramsSlug) + + await app.prisma.$transaction([ + app.prisma.card.update({ + where: { + id: card.id, + }, + data: { + viewCount: { + increment: 1, + }, + }, + }), + + app.prisma.cardView.create({ + data: { + cardId: card.id, + ownerId: card.userId, + viewerId: userId, + source: 'link', + viewerIp: ip, + viewerAgent: userAgent, + }, + }), + ]); + return reply.status(200).send(card) + } catch (error:any) { + if (error?.code === 'CARD_NOT_FOUND') { + return reply.status(404).send({ + error: error.message, + }); + } + + app.log.error(error) + handleDbError(error, request,reply) + } + }) + + //Generates qr + app.get('/:id/qr', async(request: FastifyRequest<{Params: {id: string}}>, reply:FastifyReply) => { + const cardId = request.params.id + const userId = request.user.id + + try { + const qrImage = await cardService.genrateQr(app, userId, cardId) + return reply.type('image/png').send(qrImage) + } catch (error:any) { + if (error?.code === 'CARD_NOT_FOUND') { + return reply.status(404).send({ + error: error.message, + }); + } + if (error?.code === 'CARD_PRIVATE') { + return reply.status(403).send({ + error: error.message, + }); + } + if (error?.code === 'QR_DISABLED') { + return reply.status(403).send({ + error: error.message, + }); + } + if (error?.code === 'QR_IMAGE') { + return reply.status(500).send({ + error: error.message, + }); + } + app.log.error(error) + handleDbError(error,request, reply) + } + }) + + //Get analytics + app.get('/:id/analytics', async(request:FastifyRequest<{Params: {id:string}}>, reply: FastifyReply) => { + const cardId = request.params.id + const userId = request.user.id + + try { + const analytics = await cardService.cardAnalytics(app, userId,cardId) + return reply.status(200).send(analytics) + } catch (error:any) { + if (error?.code === 'CARD_NOT_FOUND') { + return reply.status(404).send({ + error: error.message, + }); + } + app.log.error(error) + handleDbError(error , request, reply) + } + }) } diff --git a/apps/backend/src/routes/connect.ts b/apps/backend/src/routes/connect.ts index bb04194d..b0379c64 100644 --- a/apps/backend/src/routes/connect.ts +++ b/apps/backend/src/routes/connect.ts @@ -1,6 +1,10 @@ +import { randomBytes } from 'node:crypto'; + +import { decrypt, encrypt } from '../utils/encryption.js'; +import { getErrorMessage, isGitHubTokenError } from '../utils/error.util.js'; + +import type { GitHubTokenErrorResponse, GitHubTokenResponse } from '../utils/error.util.js'; import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; -import { randomBytes } from 'crypto'; -import { encrypt } from '../utils/encryption.js'; const GITHUB_AUTH_URL = 'https://github.com/login/oauth/authorize'; const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token'; @@ -11,6 +15,7 @@ const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token'; // the same OAuthToken record. Whichever flow runs last can no longer // silently overwrite the other's access token. const GITHUB_FOLLOW_PLATFORM = 'github_follow'; +const GITHUB_AUTODISCOVER_CACHE_TTL = 3600; interface OAuthCallbackQuery { code: string; @@ -22,18 +27,14 @@ interface ParsedOAuthState { nonce: string; } -export async function connectRoutes(app: FastifyInstance) { +export async function connectRoutes(app: FastifyInstance): Promise { // ─── Status ─── app.get('/status', { - preHandler: [async (request, reply) => { - const server = request.server as any; - if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } - if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } - try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } - }], - }, async (request: FastifyRequest, reply: FastifyReply) => { - const userId = (request.user as any).id; + // eslint-disable-next-line @typescript-eslint/unbound-method + preHandler: [app.authenticate], + }, async (request: FastifyRequest, _reply: FastifyReply) => { + const userId = request.user.id; const tokens = await app.prisma.oAuthToken.findMany({ where: { userId }, @@ -46,14 +47,10 @@ export async function connectRoutes(app: FastifyInstance) { // ─── GitHub Connect ─── app.get('/github', { - preHandler: [async (request, reply) => { - const server = request.server as any; - if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } - if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } - try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } - }], + // eslint-disable-next-line @typescript-eslint/unbound-method + preHandler: [app.authenticate], }, async (request: FastifyRequest, reply: FastifyReply) => { - const userId = (request.user as any).id; + const userId = request.user.id; const nonce = generateState(); // Store nonce in Redis with 10-minute TTL. @@ -102,7 +99,9 @@ export async function connectRoutes(app: FastifyInstance) { } // Consume the nonce -- one-time use only (if redis configured) - if (app.redis) await app.redis.del(`oauth:nonce:${decodedState.nonce}`); + if (app.redis) { + await app.redis.del(`oauth:nonce:${decodedState.nonce}`); + } const userId = decodedState.userId; @@ -121,10 +120,10 @@ export async function connectRoutes(app: FastifyInstance) { }), }); - const tokenData = (await tokenRes.json()) as any; + const tokenData = (await tokenRes.json()) as GitHubTokenResponse | GitHubTokenErrorResponse; - if (tokenData.error) { - app.log.error('GitHub connect token error:', tokenData); + if (isGitHubTokenError(tokenData)) { + app.log.error(tokenData, 'GitHub connect token error:'); return reply.redirect(`${process.env.PUBLIC_APP_URL}/settings?error=connect_failed`); } @@ -167,18 +166,96 @@ export async function connectRoutes(app: FastifyInstance) { } }); + app.get('/github/autodiscover', { + // eslint-disable-next-line @typescript-eslint/unbound-method + preHandler: [app.authenticate], + }, async (request: FastifyRequest, reply: FastifyReply) => { + const userId = request.user.id; + const cacheKey = `github:autodiscover:${userId}`; + + if (app.redis) { + try { + const cached = await app.redis.get(cacheKey); + if (cached) { + try { + return reply.send(JSON.parse(cached)); + } catch (err: unknown) { + app.log.warn(`Redis cache parse failed for ${cacheKey}: ${getErrorMessage(err)}`); + } + } + } catch (err: unknown) { + app.log.warn(`Redis cache read failed for ${cacheKey}: ${getErrorMessage(err)}`); + } + } + + const oauthToken = await app.prisma.oAuthToken.findUnique({ + where: { + userId_platform: { + userId, + platform: GITHUB_FOLLOW_PLATFORM, + }, + }, + select: { accessToken: true }, + }); + + if (!oauthToken) { + return reply.status(400).send({ error: 'Not connected to GitHub. Please connect GitHub first.', requiresAuth: true }); + } + + let accessToken: string; + try { + accessToken = decrypt(oauthToken.accessToken); + } catch (err: unknown) { + app.log.error({ err, userId }, 'GitHub follow token decrypt failed'); + return reply.status(500).send({ error: 'Failed to access GitHub connection' }); + } + + let response: Response; + try { + response = await fetch('https://api.github.com/user', { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/vnd.github.v3+json', + }, + }); + } catch (error: unknown) { + app.log.error({ userId, error: getErrorMessage(error) }, 'GitHub autodiscovery failed'); + return reply.status(502).send({ error: 'Failed to fetch GitHub profile' }); + } + + if (response.status === 401) { + if (app.redis) { + void Promise.resolve(app.redis.del(cacheKey)) + .catch((err: unknown) => app.log.warn(`Redis cache delete failed for ${cacheKey}: ${getErrorMessage(err)}`)); + } + return reply.status(401).send({ error: 'GitHub token expired or revoked', requiresAuth: true }); + } + + if (!response.ok) { + const body = await response.text(); + app.log.error({ status: response.status, body, userId }, 'GitHub user API request failed'); + return reply.status(502).send({ error: 'Failed to fetch GitHub profile' }); + } + + const githubUser = await response.json() as { twitter_username?: string | null; blog?: string | null; company?: string | null; bio?: string | null; html_url?: string | null }; + const suggestions = buildGitHubDiscoverySuggestions(githubUser); + + if (app.redis) { + void Promise.resolve(app.redis.set(cacheKey, JSON.stringify(suggestions), 'EX', GITHUB_AUTODISCOVER_CACHE_TTL)) + .catch((err: unknown) => app.log.warn(`Redis cache write failed for ${cacheKey}: ${getErrorMessage(err)}`)); + } + + return reply.send(suggestions); + }); + // ─── Disconnect ─── - app.delete('/:platform', { - preHandler: [async (request, reply) => { - const server = request.server as any; - if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } - if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } - try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } - }], + app.delete<{ Params: { platform: string } }>('/:platform', { + // eslint-disable-next-line @typescript-eslint/unbound-method + preHandler: [app.authenticate], }, async (request: FastifyRequest<{ Params: { platform: string } }>, reply: FastifyReply) => { - const userId = (request.user as any).id; + const userId = request.user.id; const { platform } = request.params; const SUPPORTED_PLATFORMS = ['github', 'google', 'twitter', 'linkedin']; @@ -196,7 +273,7 @@ export async function connectRoutes(app: FastifyInstance) { }, }); return { success: true }; - } catch (error) { + } catch { return reply.status(404).send({ error: 'Connection not found' }); } }); @@ -216,6 +293,76 @@ function parseOAuthState(state: string): ParsedOAuthState | null { } } +function buildGitHubDiscoverySuggestions(user: { + twitter_username?: string | null; + blog?: string | null; + company?: string | null; + bio?: string | null; + html_url?: string | null; +}): Array<{ platform: string; username: string; confidence: 'high' | 'low' }> { + const { twitter_username, blog } = user; + + const suggestions: Array<{ platform: string; username: string; confidence: 'high' | 'low' }> = []; + + if (twitter_username?.trim()) { + suggestions.push({ + platform: 'twitter', + username: twitter_username.trim(), + confidence: 'high', + }); + } + + if (blog) { + const blogSuggestion = parseBlogSuggestion(blog); + if (blogSuggestion) { + suggestions.push(blogSuggestion); + } + } + + return suggestions; +} + +function parseBlogSuggestion(blog: string): { platform: string; username: string; confidence: 'high' | 'low' } | null { + const trimmed = blog.trim(); + if (!trimmed) { + return null; + } + + const url = parseBlogUrl(trimmed); + if (!url) { + return { platform: 'portfolio', username: trimmed, confidence: 'high' }; + } + + const host = url.hostname.replace(/^www\./i, '').toLowerCase(); + const pathname = url.pathname.replace(/\/+$/, ''); + + if (host === 'dev.to' && pathname.length > 1) { + return { platform: 'devto', username: pathname.slice(1), confidence: 'low' }; + } + + if (host === 'hashnode.com' && pathname.startsWith('/@') && pathname.length > 2) { + return { platform: 'hashnode', username: pathname.slice(2), confidence: 'low' }; + } + + if (host === 'npmjs.com' && pathname.startsWith('/~') && pathname.length > 2) { + return { platform: 'npm', username: pathname.slice(2), confidence: 'low' }; + } + + return { platform: 'portfolio', username: url.href, confidence: 'high' }; +} + +function parseBlogUrl(value: string): URL | null { + try { + return new URL(value); + } catch { + try { + return new URL(`https://${value}`); + } catch { + return null; + } + } +} + function generateState(): string { return randomBytes(32).toString('hex'); } diff --git a/apps/backend/src/routes/event.ts b/apps/backend/src/routes/event.ts index 4d4ee2d9..8d7bc566 100644 --- a/apps/backend/src/routes/event.ts +++ b/apps/backend/src/routes/event.ts @@ -1,7 +1,7 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; -import { createEventSchema, joinEventSchema} from '../validations/event.validation'; +import { createEventSchema, joinEventSchema} from '../validations/event.validation.js'; -import {generateUniqueSlug} from '../utils/slug' +import {generateUniqueSlug} from '../utils/slug.js' type EventDetails = { diff --git a/apps/backend/src/routes/follow.ts b/apps/backend/src/routes/follow.ts index a152fc55..1f94a519 100644 --- a/apps/backend/src/routes/follow.ts +++ b/apps/backend/src/routes/follow.ts @@ -1,16 +1,19 @@ -import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { getPlatform, getProfileUrl, getWebViewUrl } from '@devcard/shared/src/platforms.js'; + import { decrypt } from '../utils/encryption.js'; import { getErrorMessage } from '../utils/error.util.js'; -import { getPlatform, getProfileUrl, getWebViewUrl } from '@devcard/shared'; import { followLogSchema } from '../validations/follow.validation.js'; -export async function followRoutes(app: FastifyInstance) { +import type { AuthenticatedUser } from '../types/fastify.js'; +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; + +export async function followRoutes(app: FastifyInstance): Promise { app.addHook('preHandler', async (request, reply) => { - const server = request.server as any; - if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } - if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } - try { const payload = await request.jwtVerify(); if (payload) (request as any).user = payload; } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } - }); + const server = request.server; + if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } + if (typeof app.authenticate === 'function') { await app.authenticate(request, reply); return } + try { const payload = await request.jwtVerify(); if (payload) {request.user = payload;} } catch (_e) { reply.status(401).send({ error: 'Unauthorized' }) } + }); // ─── Follow via API (Layer 1) ─── // Currently supports: GitHub @@ -19,7 +22,7 @@ export async function followRoutes(app: FastifyInstance) { request: FastifyRequest<{ Params: { platform: string; targetUsername: string } }>, reply: FastifyReply ) => { - const userId = (request.user as any).id; + const userId = request.user.id; const { platform, targetUsername } = request.params; // GitHub follow tokens are stored under 'github_follow' to prevent the @@ -116,7 +119,7 @@ export async function followRoutes(app: FastifyInstance) { }>, reply: FastifyReply ) => { - const userId = (request.user as any).id; + const userId = request.user.id; const { platform, targetUsername } = request.params; const parsed = followLogSchema.safeParse(request.body); @@ -137,8 +140,8 @@ export async function followRoutes(app: FastifyInstance) { }, }); return reply.send({ status: 'success', logId: log.id }); - } catch (error: any) { - app.log.error('Failed to log follow:', error); + } catch (error) { + app.log.error(`Failed to log follow: ${getErrorMessage(error)}`); return reply.status(500).send({ error: 'Failed to log follow event' }); } }); @@ -148,7 +151,7 @@ export async function followRoutes(app: FastifyInstance) { request: FastifyRequest<{ Params: { platform: string; targetUsername: string } }>, reply: FastifyReply ) => { - const userId = (request.user as any).id; + const userId = request.user.id; const { platform, targetUsername } = request.params; await app.prisma.followLog.deleteMany({ diff --git a/apps/backend/src/routes/profiles.ts b/apps/backend/src/routes/profiles.ts index 81026c74..388d3e02 100644 --- a/apps/backend/src/routes/profiles.ts +++ b/apps/backend/src/routes/profiles.ts @@ -1,13 +1,10 @@ -import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; -import { getProfileUrl } from '@devcard/shared'; -import { updateProfileSchema, createLinkSchema, reorderLinksSchema } from '../utils/validators.js'; -import { getErrorMessage } from '../utils/error.util.js'; -import * as profileService from '../services/profileService' +import { Prisma } from '@prisma/client'; -// ── Response types ──────────────────────────────────────────────────────────── -// Declared explicitly so the API contract is visible without tracing through -// Prisma's generic return types. Follows the convention in public.ts. +import * as profileService from '../services/profileService'; +import { updateProfileSchema, createLinkSchema, reorderLinksSchema } from '../utils/validators.js'; +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars type ProfileUpdateResponse = { id: string; email: string; @@ -21,21 +18,21 @@ type ProfileUpdateResponse = { accentColor: string; }; -export async function profileRoutes(app: FastifyInstance) { +export async function profileRoutes(app: FastifyInstance): Promise { // All profile routes require auth app.addHook('preHandler', async (request, reply) => { - const server = request.server as any; + const server = request.server; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return; } - if (typeof (app as any).authenticate === 'function') { - await (app as any).authenticate(request, reply); + if (typeof app.authenticate === 'function') { + await app.authenticate(request, reply); return; } try { await request.jwtVerify(); - } catch (e) { + } catch (_e) { reply.status(401).send({ error: 'Unauthorized' }); } }); @@ -43,16 +40,18 @@ export async function profileRoutes(app: FastifyInstance) { // ─── Get Own Profile ─── app.get('/me', async (request: FastifyRequest, reply: FastifyReply) => { - const userId = (request.user as any).id; + const userId = request.user.id; const user = await profileService.getOwnProfile(app, userId) - if (!user) return reply.status(404).send({ error: 'User not found' }) + if (!user) { + return reply.status(404).send({ error: 'User not found' }); + } return user }); // ─── Update Profile ─── app.put('/me', async (request: FastifyRequest, reply: FastifyReply) => { - const userId = (request.user as any).id; + const userId = request.user.id; const parsed = updateProfileSchema.safeParse(request.body); if (!parsed.success) { @@ -79,8 +78,10 @@ export async function profileRoutes(app: FastifyInstance) { try { const response = await profileService.updateProfile(app, userId, parsed.data) return response - } catch (err: any) { - if (err?.code === 'P2002') return reply.status(409).send({ error: 'Username already taken' }) + } catch (err: unknown) { + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') { + return reply.status(409).send({ error: 'Username already taken' }); + } app.log.error({ err }, 'DB error in PUT /profiles/me') return reply.status(500).send({ error: 'Internal server error' }) } @@ -89,7 +90,7 @@ export async function profileRoutes(app: FastifyInstance) { // ─── Add Platform Link ─── app.post('/me/links', async (request: FastifyRequest, reply: FastifyReply) => { - const userId = (request.user as any).id; + const userId = request.user.id; const parsed = createLinkSchema.safeParse(request.body); if (!parsed.success) { @@ -99,7 +100,7 @@ export async function profileRoutes(app: FastifyInstance) { try { const link = await profileService.createPlatformLink(app, userId, parsed.data) return reply.status(201).send(link) - } catch (err: any) { + } catch (err: unknown) { app.log.error({ err }, 'Failed to create platform link') return reply.status(500).send({ error: 'Internal server error' }) } @@ -108,16 +109,20 @@ export async function profileRoutes(app: FastifyInstance) { // ─── Update Platform Link ─── app.put('/me/links/:id', async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { - const userId = (request.user as any).id; + const userId = request.user.id; const { id } = request.params; const parsedReq = createLinkSchema.safeParse(request.body) - if (!parsedReq.success) return reply.status(400).send({ error: 'Validation failed', details: parsedReq.error.flatten() }) + if (!parsedReq.success) { + return reply.status(400).send({ error: 'Validation failed', details: parsedReq.error.flatten() }); + } try { const updated = await profileService.updatePlatformLink(app, userId, id, parsedReq.data) - if (!updated) return reply.status(404).send({ error: 'Link not found' }) + if (!updated) { + return reply.status(404).send({ error: 'Link not found' }); + } return updated - } catch (err: any) { + } catch (err: unknown) { app.log.error({ err }, 'Failed to update platform link') return reply.status(500).send({ error: 'Internal server error' }) } @@ -126,14 +131,16 @@ export async function profileRoutes(app: FastifyInstance) { // ─── Delete Platform Link ─── app.delete('/me/links/:id', async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { - const userId = (request.user as any).id; + const userId = request.user.id; const { id } = request.params; try { const deleted = await profileService.deletePlatformLink(app, userId, id) - if (!deleted) return reply.status(404).send({ error: 'Link not found' }) + if (!deleted) { + return reply.status(404).send({ error: 'Link not found' }); + } return reply.status(204).send() - } catch (err: any) { + } catch (err: unknown) { app.log.error({ err }, 'Failed to delete platform link') return reply.status(500).send({ error: 'Internal server error' }) } @@ -142,13 +149,15 @@ export async function profileRoutes(app: FastifyInstance) { // ─── Reorder Links ─── app.put('/me/links/reorder', async (request: FastifyRequest, reply: FastifyReply) => { - const userId = (request.user as any).id; + const userId = request.user.id; const parsedReq = reorderLinksSchema.safeParse(request.body) - if (!parsedReq.success) return reply.status(400).send({ error: 'Validation failed', details: parsedReq.error.flatten() }) + if (!parsedReq.success) { + return reply.status(400).send({ error: 'Validation failed', details: parsedReq.error.flatten() }); + } try { const resp = await profileService.reorderLinks(app, userId, parsedReq.data.links) return resp - } catch (err: any) { + } catch (err: unknown) { app.log.error({ err }, 'Failed to reorder links') return reply.status(500).send({ error: 'Internal server error' }) } diff --git a/apps/backend/src/routes/public.ts b/apps/backend/src/routes/public.ts index 27f544d8..4333b9cd 100644 --- a/apps/backend/src/routes/public.ts +++ b/apps/backend/src/routes/public.ts @@ -1,98 +1,18 @@ -import type { FastifyContextConfig, FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import * as publicService from '../services/publicService.js'; import { generateQRBuffer, generateQRSvg } from '../utils/qr.js'; -import type { PlatformLink } from '@devcard/shared'; -import { getErrorMessage } from '../utils/error.util.js'; -import * as publicService from '../services/publicService' +import type { FastifyContextConfig, FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; // ── QR size bounds ──────────────────────────────────────────────────────────── -// Enforced before any DB query or image allocation. Values outside this range -// are rejected with 400 so a single unauthenticated request cannot trigger an -// unbounded memory allocation in the QR rasteriser. const MIN_QR_SIZE = 1; const MAX_QR_SIZE = 2048; // ── Cache constants ─────────────────────────────────────────────────────────── -// Public profile cache TTL matches the Cache-Control max-age (5 minutes). -// The QR session JWT TTL is 10 minutes so an offline scan remains valid well -// beyond the HTTP cache window. -const PROFILE_CACHE_TTL = 300; // seconds (5 minutes) const CACHE_CONTROL_HEADER = 'public, max-age=300, stale-while-revalidate=60'; -type PublicProfileLink = { - id: string; - platform: string; - username: string; - url: string; - displayOrder: number; - followed?: boolean; -} - -type UsernamePublicProfileResponse = { - username: string; - displayName: string; - bio: string | null; - pronouns: string | null; - role: string | null; - company: string | null; - avatarUrl: string | null; - accentColor: string; - links: PublicProfileLink[] -} - -type PublicProfileCardLink = { - id: string; - platform: string; - username: string; - url: string; - followed?: boolean; -} - -type CardPublicProfileResponse = { - id: string; - title: string; - owner: { - username: string; - displayName: string; - bio: string | null; - avatarUrl: string | null; - accentColor: string; - }; - links: PublicProfileCardLink[] -} - -type UsernameCardPublicProfileResponse = { - title: string; - owner: { - username: string; - displayName: string; - bio: string | null; - pronouns: string | null; - role: string | null; - company: string | null; - avatarUrl: string | null; - accentColor: string; - }; - links: PublicProfileCardLink[] -} - -// Represents a CardLink record with the joined PlatformLink relation -interface CardLinkWithPlatform { - id: string; - displayOrder: number; - platformLink: PlatformLink; -} - -// ── Internal Redis cache shape ──────────────────────────────────────────────── -// Extends the public response with the owner's DB id so that background view -// tracking can still fire on cache-HIT requests without an extra DB read. -type CachedProfileEntry = UsernamePublicProfileResponse & { _userId: string }; - - -export async function publicRoutes(app: FastifyInstance) { +export async function publicRoutes(app: FastifyInstance): Promise { // ─── Public Profile ─────────────────────────────────────────────────────── - // ─── Public Profile ─── - /** + /** * GET /api/u/:username * Returns the public profile information for a user. */ @@ -105,29 +25,32 @@ export async function publicRoutes(app: FastifyInstance) { }, }, async (request: FastifyRequest<{ Params: { username: string } }>, reply: FastifyReply) => { const { username } = request.params; - const cacheKey = `profile:${username}`; - // Try to extract viewer from Authorization header (soft auth). - let viewerId: string | null = null + // Soft auth: extract viewer id if token present. + // authenticatedUserId is used to detect self-views; viewerId is only set + // for other authenticated users so the service knows who is viewing. + let viewerId: string | null = null; + let authenticatedUserId: string | null = null; try { if (request.headers.authorization) { - const decoded = (await request.jwtVerify()) as { id?: string } - viewerId = decoded?.id ?? null - } else { - viewerId = null + const decoded = (await request.jwtVerify()) as { id?: string }; + authenticatedUserId = decoded?.id ?? null; + viewerId = authenticatedUserId; } } catch { - // ignored + // ignored — treat as unauthenticated } try { - const result = await publicService.getPublicProfile(app, username, viewerId, request) - if (!result) return reply.status(404).send({ error: 'User not found' }) - reply.header('X-Cache', result.cached ? 'HIT' : 'MISS').header('Cache-Control', CACHE_CONTROL_HEADER) - return result.data - } catch (err: any) { - app.log.error({ err }, 'Failed to fetch public profile') - return reply.status(500).send({ error: 'Internal server error' }) + const result = await publicService.getPublicProfile(app, username, viewerId, request, authenticatedUserId); + if (!result) { + return reply.status(404).send({ error: 'User not found' }); + } + reply.header('X-Cache', result.cached ? 'HIT' : 'MISS').header('Cache-Control', CACHE_CONTROL_HEADER); + return result.data; + } catch (err: unknown) { + app.log.error({ err }, 'Failed to fetch public profile'); + return reply.status(500).send({ error: 'Internal server error' }); } }); @@ -149,18 +72,35 @@ export async function publicRoutes(app: FastifyInstance) { const { cardId } = request.params; try { - const card = await publicService.getCardById(app, cardId) - if (!card) return reply.status(404).send({ error: 'Card not found' }) - const response = { id: card.id, title: card.title, owner: { username: card.user.username, displayName: card.user.displayName, bio: card.user.bio, avatarUrl: card.user.avatarUrl, accentColor: card.user.accentColor }, links: card.cardLinks.map((cl: any) => ({ id: cl.platformLink.id, platform: cl.platformLink.platform, username: cl.platformLink.username, url: cl.platformLink.url })) } - return response - } catch (err: any) { - app.log.error({ err }, 'Failed to fetch shared card') - return reply.status(500).send({ error: 'Internal server error' }) + const card = await publicService.getCardById(app, cardId); + if (!card) { + return reply.status(404).send({ error: 'Card not found' }); + } + const response = { + id: card.id, + title: card.title, + owner: { + username: card.user.username, + displayName: card.user.displayName, + bio: card.user.bio, + avatarUrl: card.user.avatarUrl, + accentColor: card.user.accentColor, + }, + links: card.cardLinks.map((cl: any) => ({ + id: cl.platformLink.id, + platform: cl.platformLink.platform, + username: cl.platformLink.username, + url: cl.platformLink.url, + })), + }; + return response; + } catch (err: unknown) { + app.log.error({ err }, 'Failed to fetch shared card'); + return reply.status(500).send({ error: 'Internal server error' }); } }); // ─── Public Card View ───────────────────────────────────────────────────── - // ─── Public Card View ─── /** * GET /api/u/:username/card/:cardId * Returns full owner profile + specific card data. @@ -176,30 +116,31 @@ export async function publicRoutes(app: FastifyInstance) { }, async (request: FastifyRequest<{ Params: { username: string; cardId: string } }>, reply: FastifyReply) => { const { username, cardId } = request.params; - let viewerId: string | null = null + let viewerId: string | null = null; + let authenticatedUserId: string | null = null; try { if (request.headers.authorization) { - const decoded = (await request.jwtVerify()) as { id?: string } - viewerId = decoded?.id ?? null + const decoded = (await request.jwtVerify()) as { id?: string }; + authenticatedUserId = decoded?.id ?? null; + viewerId = authenticatedUserId; } } catch { // ignored } try { - const result = await publicService.getUserCard(app, username, cardId, viewerId, request) - if (result.notFound) return reply.status(404).send({ error: 'User or card not found' }) - return result.data - } catch (err: any) { - app.log.error({ err }, 'Failed to fetch user card') - return reply.status(500).send({ error: 'Internal server error' }) + const result = await publicService.getUserCard(app, username, cardId, viewerId, request, authenticatedUserId); + if (result.notFound) { + return reply.status(404).send({ error: 'User or card not found' }); + } + return result.data; + } catch (err: unknown) { + app.log.error({ err }, 'Failed to fetch user card'); + return reply.status(500).send({ error: 'Internal server error' }); } }); // ─── QR Session ────────────────────────────────────────────────────────── - // Returns a short-lived signed JWT encoding the public profile snapshot. - // Intended for native apps to generate QR codes that remain scannable when - // the device has no live network connectivity (offline QR mode, spec §5.9). app.get('/:username/qr-session', { config: { rateLimit: { @@ -209,20 +150,21 @@ export async function publicRoutes(app: FastifyInstance) { } as FastifyContextConfig }, async (request: FastifyRequest<{ Params: { username: string } }>, reply: FastifyReply) => { const { username } = request.params; - const cacheKey = `profile:${username}`; try { - const result = await publicService.getPublicProfile(app, username, null, request) - if (!result) return reply.status(404).send({ error: 'User not found' }) - const snapshot = result.data - const expiresIn = 600 - const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString() - const token = app.jwt.sign({ profile: snapshot, sub: username }, { expiresIn: '10m' }) - reply.header('Cache-Control', CACHE_CONTROL_HEADER) - return { token, tokenType: 'JWT', expiresIn, expiresAt } - } catch (err: any) { - app.log.error({ err }, 'Failed to create qr-session') - return reply.status(500).send({ error: 'Internal server error' }) + const result = await publicService.getPublicProfile(app, username, null, request, null); + if (!result) { + return reply.status(404).send({ error: 'User not found' }); + } + const snapshot = result.data; + const expiresIn = 600; + const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString(); + const token = app.jwt.sign({ profile: snapshot, sub: username }, { expiresIn: '10m' }); + reply.header('Cache-Control', CACHE_CONTROL_HEADER); + return { token, tokenType: 'JWT', expiresIn, expiresAt }; + } catch (err: unknown) { + app.log.error({ err }, 'Failed to create qr-session'); + return reply.status(500).send({ error: 'Internal server error' }); } }); @@ -231,7 +173,7 @@ export async function publicRoutes(app: FastifyInstance) { app.get('/:username/qr', { config: { rateLimit: { - max: 50, // Lower limit for QR generation as it's more resource intensive + max: 50, timeWindow: '1 minute' } } as FastifyContextConfig @@ -242,9 +184,6 @@ export async function publicRoutes(app: FastifyInstance) { const { username } = request.params; const format = (request.query as any).format || 'png'; - // Parse and validate size before touching the DB or allocating any buffers. - // parseInt safely handles non-numeric strings (returns NaN) and ignores any - // trailing fractional part, so '400.9' → 400 which is within bounds. const rawSize = (request.query as any).size; const size = rawSize !== undefined ? parseInt(rawSize, 10) : 400; @@ -254,7 +193,6 @@ export async function publicRoutes(app: FastifyInstance) { }); } - // Verify user exists const user = await app.prisma.user.findUnique({ where: { username }, }); @@ -267,14 +205,21 @@ export async function publicRoutes(app: FastifyInstance) { try { if (format === 'svg') { - const svg = await generateQRSvg(profileUrl, { width: size }) - return reply.header('Content-Type', 'image/svg+xml').header('Content-Disposition', `inline; filename="devcard-${username}.svg"`).send(svg) + const svg = await generateQRSvg(profileUrl, { width: size }); + return reply + .header('Content-Type', 'image/svg+xml') + .header('Content-Disposition', `inline; filename="devcard-${username}.svg"`) + .send(svg); } - const png = await generateQRBuffer(profileUrl, { width: size }) - return reply.header('Content-Type', 'image/png').header('Content-Disposition', `inline; filename="devcard-${username}.png"`).send(png) + + const png = await generateQRBuffer(profileUrl, { width: size }); + return reply + .header('Content-Type', 'image/png') + .header('Content-Disposition', `inline; filename="devcard-${username}.png"`) + .send(png); } catch (error) { - app.log.error({ error, username, size, format }, 'QR generation failed') - return reply.status(500).send({ error: 'QR code generation failed' }) + app.log.error({ error, username, size, format }, 'QR generation failed'); + return reply.status(500).send({ error: 'QR code generation failed' }); } }); -} +} \ No newline at end of file diff --git a/apps/backend/src/routes/team.ts b/apps/backend/src/routes/team.ts index af177e52..3ee44876 100644 --- a/apps/backend/src/routes/team.ts +++ b/apps/backend/src/routes/team.ts @@ -1,10 +1,10 @@ import {Prisma, TeamRole } from '@prisma/client'; import QRCode from 'qrcode' -import {generateUniqueSlug} from '../utils/slug' -import { createTeamScehma,inviteMembers,updateTeam } from '../validations/team.validation'; +import {generateUniqueSlug} from '../utils/slug.js' +import { createTeamScehma,inviteMembers,updateTeam } from '../validations/team.validation.js'; -import type {PlatformLink, PublicProfile} from '@devcard/shared' +import type {PlatformLink, PublicProfile} from '@devcard/shared/src/types.js' import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; type TeamMember = PublicProfile & { diff --git a/apps/backend/src/services/cardService.ts b/apps/backend/src/services/cardService.ts index a9721783..bbd776dc 100644 --- a/apps/backend/src/services/cardService.ts +++ b/apps/backend/src/services/cardService.ts @@ -1,93 +1,378 @@ -import type { FastifyInstance } from 'fastify' -import type { Prisma } from '@prisma/client' +import { type Card, CardVisibility, type Prisma } from '@prisma/client'; +import QRCode from 'qrcode'; -export async function listCards(app: FastifyInstance, userId: string) { - const cards = await app.prisma.card.findMany({ +import { generateUniqueSlug } from '../utils/slug'; + +import type { CreateCardBody } from '../routes/cards'; +import type { FastifyInstance } from 'fastify'; + +type CardLinkResponse = { platformLink: unknown }; +type RawCard = { id: string; title: string; isDefault: boolean; cardLinks: CardLinkResponse[] }; +export type CardResponse = { id: string; title: string; isDefault: boolean; links: unknown[] }; + + +export interface UpdateCardBody{ + title?:string; + description?:string; + visibility?: CardVisibility; + qrEnabled?: boolean; +} + + +function mapCard(card: RawCard): CardResponse { + return { + id: card.id, + title: card.title, + isDefault: card.isDefault, + links: card.cardLinks.map((cardLink) => cardLink.platformLink), + }; +} + +//List card service +export async function listCards(app: FastifyInstance, userId: string): Promise { + const cards = (await app.prisma.card.findMany({ where: { userId }, take: 50, include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } }, orderBy: { createdAt: 'asc' }, - }) + })) as unknown as RawCard[]; - return cards.map((card: any) => ({ id: card.id, title: card.title, isDefault: card.isDefault, links: card.cardLinks.map((cl: any) => cl.platformLink) })) + return cards.map(mapCard); } -export async function createCard(app: FastifyInstance, userId: string, body: { title: string; linkIds: string[] }) { - if (body.linkIds.length > 0) { - const ownedLinks = await app.prisma.platformLink.findMany({ where: { id: { in: body.linkIds }, userId }, select: { id: true } }) - if (ownedLinks.length !== body.linkIds.length) throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' }) - } +//Creates card service +export async function createCard(app: FastifyInstance, userId: string, body: CreateCardBody): Promise { + const {title , description , linkIds , visibility} = body - const cardCount = await app.prisma.card.count({ where: { userId } }) + const ownedLinks = await app.prisma.platformLink.findMany({ + where: { id: { in: linkIds }, userId }, + select: { id: true }, + }); - const card = await app.prisma.card.create({ - data: { - userId, - title: body.title, - isDefault: cardCount === 0, - cardLinks: { create: body.linkIds.map((linkId, index) => ({ platformLinkId: linkId, displayOrder: index })) }, - }, - include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } }, + if (ownedLinks.length !== linkIds.length) { + throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' }); + } + + const finalSlug = await generateUniqueSlug(title, async(slug) => { + const existing = await app.prisma.card.findUnique({ + where: { + slug + } + }) + return !!existing }) - return { id: card.id, title: card.title, isDefault: card.isDefault, links: card.cardLinks.map((cl: any) => cl.platformLink) } -} -export async function updateCard(app: FastifyInstance, userId: string, id: string, body: { title?: string; linkIds?: string[] }) { - const existing = await app.prisma.card.findFirst({ where: { id, userId } }) - if (!existing) return null + const maxRetries = 3; + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const card = (await app.prisma.$transaction( + async (tx: Prisma.TransactionClient) => { + const cardCount = await tx.card.count({ where: { userId } }); + + return tx.card.create({ + data: { + userId, + title, + slug: finalSlug, + isDefault: cardCount === 0, + description, + visibility: visibility ?? CardVisibility.PUBLIC, + cardLinks: { + create: linkIds.map((linkId, index) => ({ platformLinkId: linkId, displayOrder: index })), + }, + }, + include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } }, + }); + }, + { + isolationLevel: 'Serializable', + }, + )) as unknown as RawCard; - if (body.title) { - await app.prisma.card.update({ where: { id }, data: { title: body.title } }) + return mapCard(card); + } catch (error: unknown) { + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code: string }).code === 'P2034' && + attempt < maxRetries + ) { + continue; + } + app.log.error(error); + throw error + } } - if (body.linkIds) { - if (body.linkIds.length > 0) { - const ownedLinks = await app.prisma.platformLink.findMany({ where: { id: { in: body.linkIds }, userId }, select: { id: true } }) - if (ownedLinks.length !== body.linkIds.length) throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' }) + throw new Error('Failed to create card after retrying serialization conflicts'); +} + +//Update card service +export async function updateCard( + app: FastifyInstance, + userId: string, + id: string, + body: UpdateCardBody, +): Promise { + const {title, description, visibility, qrEnabled} = body + + const existing = await app.prisma.card.findFirst({ where: { id, userId } }); + if (!existing) { + throw Object.assign(new Error('NotFound'), { code: 'NOT_FOUND' }); } - const linkIds = body.linkIds - await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => { - await tx.cardLink.deleteMany({ where: { cardId: id } }) - if (linkIds.length > 0) { - await tx.cardLink.createMany({ data: linkIds.map((linkId, index) => ({ cardId: id, platformLinkId: linkId, displayOrder: index })) }) + const updated = await app.prisma.card.update({ + where: { + id, + }, + data:{ + title, + description, + visibility, + qrEnabled } - }) - } + }) - const updated = await app.prisma.card.findUnique({ where: { id }, include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } } }) - return { id: updated!.id, title: updated!.title, isDefault: updated!.isDefault, links: updated!.cardLinks.map((cl: any) => cl.platformLink) } + return updated; } -export async function deleteCard(app: FastifyInstance, userId: string, id: string) { +//Delete card service +export async function deleteCard(app: FastifyInstance, userId: string, id: string): Promise { return await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => { - const existing = await tx.card.findFirst({ where: { id, userId } }) - if (!existing) return Object.assign(new Error('NotFound'), { code: 'NOT_FOUND' }) + const existing = await tx.card.findFirst({ where: { id, userId } }); + if (!existing) { + throw Object.assign(new Error('NotFound'), { code: 'NOT_FOUND' }); + } - const userCardCount = await tx.card.count({ where: { userId } }) - if (userCardCount <= 1) return Object.assign(new Error('Cannot delete last card'), { code: 'LAST_CARD' }) + const userCardCount = await tx.card.count({ where: { userId } }); + if (userCardCount <= 1) { + throw Object.assign(new Error('Cannot delete last card'), { code: 'LAST_CARD' }); + } if (existing.isDefault) { - const oldestRemainingCard = await tx.card.findFirst({ where: { userId, id: { not: id } }, orderBy: { createdAt: 'asc' } }) + const oldestRemainingCard = await tx.card.findFirst({ + where: { userId, id: { not: id } }, + orderBy: { createdAt: 'asc' }, + }); + if (oldestRemainingCard) { - await tx.card.update({ where: { id: oldestRemainingCard.id }, data: { isDefault: true } }) + await tx.card.update({ where: { id: oldestRemainingCard.id }, data: { isDefault: true } }); } } - await tx.card.delete({ where: { id } }) - return null - }) + await tx.card.delete({ where: { id } }); + return null; + }); } -export async function setDefaultCard(app: FastifyInstance, userId: string, id: string) { - const existing = await app.prisma.card.findFirst({ where: { id, userId } }) - if (!existing) return null +//Set default card service +export async function setDefaultCard(app: FastifyInstance, userId: string, id: string): Promise<{ message: string } | null> { + const existing = await app.prisma.card.findFirst({ where: { id, userId } }); + + if (!existing) { + throw Object.assign(new Error('NotFound'), { code: 'NOT_FOUND' }); + } await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => { - await tx.card.updateMany({ where: { userId }, data: { isDefault: false } }) - await tx.card.update({ where: { id }, data: { isDefault: true } }) + await tx.card.updateMany({ where: { userId }, data: { isDefault: false } }); + await tx.card.update({ where: { id }, data: { isDefault: true } }); + }); + + return { message: 'Default card updated' }; +} + +//Adds platfrom link +export async function addPlatFormLinks(app: FastifyInstance, userId: string, id:string, platformLinkId: string): Promise { + const ownedCard = await app.prisma.card.findFirst({ + where: { + id, + userId + } + }) + + if (!ownedCard) { + throw Object.assign( + new Error('Card not found or you do not have permission to modify it'), + { code: 'CARD_NOT_FOUND' } + ); + } + const [existingLink, platformLink] = await Promise.all([ + app.prisma.cardLink.findUnique({ + where: { + cardId_platformLinkId: { + cardId: id, + platformLinkId, + }, + }, + }), + + app.prisma.platformLink.findFirst({ + where: { + id: platformLinkId, + userId, + }, + }), + ]); + + if (!platformLink) { + throw Object.assign( + new Error('Platform link not found or does not belong to your account'), + { code: 'PLATFORM_LINK_NOT_FOUND' } + ); + } + + if (existingLink) { + throw Object.assign( + new Error('This platform link has already been added to the card'), + { code: 'LINK_ALREADY_EXISTS' } + ); + } + + await app.prisma.cardLink.create({ + data: { + cardId: id, + platformLinkId + } + }) +} + +//Shares card +export async function shareCard(app: FastifyInstance, userId:string, id: string): Promise<{ shareUrl: string }> { + const card = await app.prisma.card.findFirst({ + where:{ + id, + userId + } + }) + + if (!card) { + throw Object.assign( + new Error('Card not found'), + { code: 'CARD_NOT_FOUND' } + ); + } + + + if(card?.visibility === CardVisibility.PRIVATE){ + throw Object.assign( + new Error('Private cards cannot be shared'), + { code: 'CARD_PRIVATE' } + ); + } + + return { + shareUrl: `/cards/share/${card.slug}`, + }; +} + +//Gets share card +export async function getSharedCard(app:FastifyInstance, slug:string): Promise> { + const card = await app.prisma.card.findUnique({ + where: { + slug + }, + include: { + cardLinks: { + include: { + platformLink: true + } + } + } + }) + + if(!card){ + throw Object.assign( + new Error('Card not found'), + { code: 'CARD_NOT_FOUND' } + ); + } + + return card +} + +//Genreate qr +export async function genrateQr(app: FastifyInstance,userId:string, id: string): Promise { + const card = await app.prisma.card.findFirst({ + where:{ + id, + userId + } }) - return { message: 'Default card updated' } + if (!card) { + throw Object.assign( + new Error('Card not found'), + { code: 'CARD_NOT_FOUND' } + ); + } + + + if(card?.visibility === CardVisibility.PRIVATE){ + throw Object.assign( + new Error('Private cards cannot be shared'), + { code: 'CARD_PRIVATE' } + ); + } + + if(!card.qrEnabled){ + throw Object.assign( + new Error('QR is not availbled for this card'), + { code: 'QR_DISABLED' } + ); + } + + const shareUrl = `${process.env.MOBILE_REDIRECT_URI}/cards/share/${card.slug}` + const qrImage = await QRCode.toBuffer(shareUrl); + + if(!qrImage){ + throw Object.assign( + new Error('QR generation failed'), + { code: 'QR_IMAGE' } + ); + } + + return qrImage; + + } + +//TODO:Add pagination +export async function cardAnalytics(app: FastifyInstance, userId:string, id: string): Promise> { + const card = await app.prisma.card.findFirst({ + where: { + id, + userId + }, + include: { + views: { + orderBy: { + createdAt: 'desc' + }, + include: { + viewer : { + select: { + id:true, + username: true, + avatarUrl: true, + displayName: true, + role: true, + accentColor: true + } + } + } + } + }, + + }) + + if (!card) { + throw Object.assign( + new Error('Card not found'), + { code: 'CARD_NOT_FOUND' } + ); + } + + return card +} \ No newline at end of file diff --git a/apps/backend/src/services/profileService.ts b/apps/backend/src/services/profileService.ts index dc97b2a4..4d300091 100644 --- a/apps/backend/src/services/profileService.ts +++ b/apps/backend/src/services/profileService.ts @@ -1,6 +1,5 @@ import type { FastifyInstance } from 'fastify' -import { getProfileUrl } from '@devcard/shared' -import type { PlatformLink } from '@devcard/shared' +import { getProfileUrl } from '@devcard/shared/src/platforms.js' import { getErrorMessage } from '../utils/error.util.js' export async function getOwnProfile(app: FastifyInstance, userId: string) { diff --git a/apps/backend/src/services/publicService.ts b/apps/backend/src/services/publicService.ts index 758ab78f..734686bb 100644 --- a/apps/backend/src/services/publicService.ts +++ b/apps/backend/src/services/publicService.ts @@ -1,10 +1,16 @@ -import type { FastifyInstance } from 'fastify' import { getErrorMessage } from '../utils/error.util.js' +import type { FastifyInstance } from 'fastify' + const PROFILE_CACHE_TTL = 300 -const CACHE_CONTROL_HEADER = 'public, max-age=300, stale-while-revalidate=60' -export async function getPublicProfile(app: FastifyInstance, username: string, viewerId: string | null, request: any) { +export async function getPublicProfile( + app: FastifyInstance, + username: string, + viewerId: string | null, + request: any, + authenticatedUserId: string | null = null, +): Promise<{ cached: boolean; data: object; cacheKey: string } | null> { const cacheKey = `profile:${username}` if (app.redis) { @@ -12,7 +18,9 @@ export async function getPublicProfile(app: FastifyInstance, username: string, v const cached = await app.redis.get(cacheKey) if (cached) { const { _userId, ...profileData } = JSON.parse(cached) - if (viewerId && viewerId !== _userId) { + // Only record a view if the viewer is not the owner + const isSelfView = authenticatedUserId !== null && authenticatedUserId === _userId + if (viewerId && !isSelfView) { app.prisma.cardView.create({ data: { ownerId: _userId, cardId: null, viewerId, viewerIp: request.ip || null, viewerAgent: request.headers['user-agent'] || null, source: request.query?.source || 'link' } }).catch((err: unknown) => app.log.error(`Failed to log view: ${getErrorMessage(err)}`)) } return { cached: true, data: profileData, cacheKey } @@ -23,9 +31,11 @@ export async function getPublicProfile(app: FastifyInstance, username: string, v } const user = await app.prisma.user.findUnique({ where: { username }, include: { platformLinks: { orderBy: { displayOrder: 'asc' } } } }) - if (!user) return null + if (!user) { return null } - if (viewerId && viewerId !== user.id) { + // Block self-views: don't record a cardView if the authenticated user is the owner + const isSelfView = authenticatedUserId !== null && authenticatedUserId === user.id + if (viewerId && !isSelfView) { app.prisma.cardView.create({ data: { ownerId: user.id, cardId: null, viewerId, viewerIp: request.ip || null, viewerAgent: request.headers['user-agent'] || null, source: request.query?.source || 'link' } }).catch((error: unknown) => app.log.error(`Failed to log view: ${getErrorMessage(error)}`)) } @@ -47,21 +57,30 @@ export async function getPublicProfile(app: FastifyInstance, username: string, v return { cached: false, data: response, cacheKey } } -export async function getCardById(app: FastifyInstance, cardId: string) { +export async function getCardById(app: FastifyInstance, cardId: string): Promise { const card = await app.prisma.card.findUnique({ where: { id: cardId }, include: { user: true, cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } } }) return card } -export async function getUserCard(app: FastifyInstance, username: string, cardId: string, viewerId: string | null, request: any) { +export async function getUserCard( + app: FastifyInstance, + username: string, + cardId: string, + viewerId: string | null, + request: any, + authenticatedUserId: string | null = null, +): Promise<{ notFound: boolean; data?: object }> { const user = await app.prisma.user.findUnique({ where: { username } }) - if (!user) return { notFound: true } + if (!user) { return { notFound: true } } const card = await app.prisma.card.findFirst({ where: { id: cardId, userId: user.id }, include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } } }) - if (!card) return { notFound: true } + if (!card) { return { notFound: true } } - if (viewerId && viewerId !== user.id) { + // Block self-views: don't record a cardView if the authenticated user is the owner + const isSelfView = authenticatedUserId !== null && authenticatedUserId === user.id + if (viewerId && !isSelfView) { app.prisma.cardView.create({ data: { ownerId: user.id, cardId: card.id, viewerId, viewerIp: request.ip || null, viewerAgent: request.headers['user-agent'] || null, source: request.query?.source || 'qr' } }).catch((error: unknown) => app.log.error(`Failed to log view: ${getErrorMessage(error)}`)) } const response = { title: card.title, owner: { username: user.username, displayName: user.displayName, bio: user.bio, pronouns: user.pronouns, role: user.role, company: user.company, avatarUrl: user.avatarUrl, accentColor: user.accentColor }, links: card.cardLinks.map((cl: any) => ({ id: cl.platformLink.id, platform: cl.platformLink.platform, username: cl.platformLink.username, url: cl.platformLink.url, displayOrder: cl.displayOrder })) } return { notFound: false, data: response } -} +} \ No newline at end of file diff --git a/apps/backend/src/types/fastify.d.ts b/apps/backend/src/types/fastify.d.ts index 8e7aee95..faeddd2a 100644 --- a/apps/backend/src/types/fastify.d.ts +++ b/apps/backend/src/types/fastify.d.ts @@ -1,8 +1,24 @@ import '@fastify/cookie'; -import { FastifyRequest } from 'fastify'; +import '@fastify/jwt'; +import { FastifyReply, FastifyRequest } from 'fastify'; + +export interface AuthenticatedUser { + id: string; + username: string; +} + +declare module '@fastify/jwt' { + interface FastifyJWT { + user: AuthenticatedUser; + } +} declare module 'fastify' { interface FastifyRequest { cookies: Record; } + + interface FastifyInstance { + authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise; + } } diff --git a/apps/backend/src/utils/error.util.ts b/apps/backend/src/utils/error.util.ts index fef1b98b..d429f1fb 100644 --- a/apps/backend/src/utils/error.util.ts +++ b/apps/backend/src/utils/error.util.ts @@ -1,11 +1,38 @@ -import type { FastifyReply, FastifyRequest } from 'fastify'; import { Prisma } from '@prisma/client'; +import type { FastifyReply, FastifyRequest } from 'fastify'; + +interface GoogleTokenResponse { + access_token: string; + refresh_token?: string; + expires_in: number; + token_type: string; + scope?: string; +} + +interface GoogleTokenErrorResponse { + error: string; + error_description?: string; +} + +export interface GitHubTokenResponse { + access_token: string; + token_type: string; + scope: string; +} + +export interface GitHubTokenErrorResponse { + error: string; + error_description?: string; +} + + + export function getErrorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } -export function handleDbError(error: unknown, request: FastifyRequest, reply: FastifyReply) { +export function handleDbError(error: unknown, request: FastifyRequest, reply: FastifyReply): FastifyReply { request.log.error(error); if (error instanceof Prisma.PrismaClientKnownRequestError) { @@ -29,4 +56,16 @@ export function handleDbError(error: unknown, request: FastifyRequest, reply: Fa } return reply.status(500).send({ error: 'Internal Server Error' }); +} + +export function isGoogleTokenError( + data: GoogleTokenResponse | GoogleTokenErrorResponse, +): data is GoogleTokenErrorResponse { + return 'error' in data; +} + +export function isGitHubTokenError( + data: GitHubTokenResponse | GitHubTokenErrorResponse, +): data is GitHubTokenErrorResponse { + return 'error' in data; } \ No newline at end of file diff --git a/apps/backend/src/utils/jwt.ts b/apps/backend/src/utils/jwt.ts new file mode 100644 index 00000000..de026333 --- /dev/null +++ b/apps/backend/src/utils/jwt.ts @@ -0,0 +1,40 @@ +import { createHash } from 'node:crypto'; + +import type { FastifyInstance, FastifyRequest } from 'fastify'; + + + +export function signAccessToken(app: FastifyInstance, user: {id:string, username:string}):string{ + return app.jwt.sign( + { + id: user.id, + username: user.username + },{ + expiresIn: '15m' + } + ) +} + +/** + * Extract the raw JWT string from a Fastify request. + * Precedence: Authorization: Bearer header → `token` cookie. + * Returns null if neither is present. + */ +export function extractRawJwt(request: FastifyRequest): string | null { + const auth = request.headers.authorization; + if (auth?.startsWith('Bearer ')) { return auth.slice(7) || null; } + return request.cookies?.access_Token || null; +} + +/** + * Compute the Redis blocklist key for a raw JWT. + * + * Only the signature segment (third JWT segment) is hashed. The signature is + * unique per token because it is an HMAC over the header + payload, so it + * identifies the token without storing any claims in Redis. SHA-256 of the + * signature also means the Redis key leaks nothing if Redis is compromised. + */ +export function blocklistKey(rawJwt: string): string { + const sig = rawJwt.split('.')[2] ?? rawJwt; + return `blocklist:${createHash('sha256').update(sig).digest('hex')}`; +} diff --git a/apps/backend/src/utils/oauth.ts b/apps/backend/src/utils/oauth.ts new file mode 100644 index 00000000..9dff87fe --- /dev/null +++ b/apps/backend/src/utils/oauth.ts @@ -0,0 +1,31 @@ +import { randomBytes } from 'node:crypto'; + +export function generateState(): string { + return randomBytes(32).toString('hex'); +} + +export function buildOAuthState(clientState: string, mobileRedirectUri: string): string { + if (!clientState) { + return generateState(); + } + if (clientState.startsWith('mobile_') && mobileRedirectUri) { + const encodedRedirect = Buffer.from(mobileRedirectUri, 'utf8').toString('base64url'); + return `${clientState}.${encodedRedirect}.${generateState()}`; + } + return `${clientState}.${generateState()}`; +} + +export function getMobileRedirectUri(state?: string): string | null { + if (!state?.startsWith('mobile_')) { + return null; + } + const encodedRedirect = state.split('.')[1]; + if (!encodedRedirect) { + return null; + } + try { + return Buffer.from(encodedRedirect, 'base64url').toString('utf8'); + } catch { + return null; + } +} diff --git a/apps/backend/src/utils/refreshToken.ts b/apps/backend/src/utils/refreshToken.ts new file mode 100644 index 00000000..227ff0ad --- /dev/null +++ b/apps/backend/src/utils/refreshToken.ts @@ -0,0 +1,19 @@ +import crypto from 'node:crypto'; + +export function generateRefreshToken():string { + return crypto.randomBytes(64).toString('hex'); +} + +export function hashRefreshToken(token: string):string { + return crypto + .createHash('sha256') + .update(token) + .digest('hex'); +} + +export function hashIp(ip: string): string { + return crypto + .createHash('sha256') + .update(ip) + .digest('hex'); +} \ No newline at end of file diff --git a/apps/backend/src/utils/validators.ts b/apps/backend/src/utils/validators.ts index bd41bef2..2ec69c1c 100644 --- a/apps/backend/src/utils/validators.ts +++ b/apps/backend/src/utils/validators.ts @@ -1,5 +1,5 @@ +import { getPlatform } from '@devcard/shared/src/platforms'; import { z } from 'zod'; -import { getPlatform } from '@devcard/shared'; export const updateProfileSchema = z.object({ displayName: z.string().min(1).max(100).optional(), @@ -45,12 +45,3 @@ export const reorderLinksSchema = z.object({ ), }); -export const createCardSchema = z.object({ - title: z.string().min(1).max(100), - linkIds: z.array(z.string().uuid()), -}); - -export const updateCardSchema = z.object({ - title: z.string().min(1).max(100).optional(), - linkIds: z.array(z.string().uuid()).optional(), -}); diff --git a/apps/backend/src/validations/auth.validation.ts b/apps/backend/src/validations/auth.validation.ts new file mode 100644 index 00000000..b6db0dc8 --- /dev/null +++ b/apps/backend/src/validations/auth.validation.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; + +export const oAuthStartSchema = z.object({ + state: z.string().optional().default(''), + mobile_redirect_uri: z + .string() + .optional() + .default('') + .refine( + (val) => !val || val.startsWith('devcard://'), + { message: 'Invalid mobile redirect URI' } + ), +}); + +export type OAuthStartQuery = z.infer; + +export const oAuthCallbackSchema = z.object({ + code: z.string().trim().min(1, 'Authorization code is required'), + state: z.string().trim().min(1, 'State parameter is required'), +}); + +export type OAuthCallbackQuery = z.infer; diff --git a/apps/backend/src/validations/card.validation.ts b/apps/backend/src/validations/card.validation.ts new file mode 100644 index 00000000..21257501 --- /dev/null +++ b/apps/backend/src/validations/card.validation.ts @@ -0,0 +1,44 @@ +import { CardVisibility } from '@prisma/client'; +import {z} from 'zod' + +export const createCardSchema = z.object({ + title: z.string().min(1).max(100), + + linkIds: z + .array(z.string().uuid()) + .nonempty({ + message: 'At least one link is required', + }) + .refine( + (ids) => new Set(ids).size === ids.length, + { + message: 'Duplicate links are not allowed', + } + ), + description: z.string().min(1).max(100).optional(), + visibility: z.nativeEnum(CardVisibility).optional(), +}); + +export const updateCardSchema = z + .object({ + title: z.string().min(1).max(100).optional(), + description: z.string().min(1).max(100).optional(), + visibility: z.nativeEnum(CardVisibility).optional(), + qrEnabled: z.boolean().optional(), + }) + .refine( + (data) => + data.title !== undefined || + data.description !== undefined || + data.visibility !== undefined || + data.qrEnabled !== undefined, + { + message: 'At least one field must be provided', + } +); + +export const addPlatformLinkSchema = z.object({ + platformLinkId: z.string().uuid({ + message: 'Invalid platform link ID', + }), +}); \ No newline at end of file diff --git a/apps/mobile/Gemfile b/apps/mobile/Gemfile index 6a4c5f17..51515233 100644 --- a/apps/mobile/Gemfile +++ b/apps/mobile/Gemfile @@ -14,3 +14,4 @@ gem 'bigdecimal' gem 'logger' gem 'benchmark' gem 'mutex_m' +gem 'nkf' diff --git a/apps/mobile/README.md b/apps/mobile/README.md index 32e69ac0..3e2c3f85 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -1,51 +1,97 @@ -# DevCard Mobile +This is a new [**React Native**](https://reactnative.dev) project, bootstrapped using [`@react-native-community/cli`](https://github.com/react-native-community/cli). -The mobile application for DevCard, built with bare **React Native** and **React Navigation**. +# Getting Started -This app provides: -- Profile and context card management -- Per-Platform OAuth Connections for silent API follows -- Advanced analytics for tracking profile views -- The Hybrid Follow Engine (API, WebView, Link) +> **Note**: Make sure you have completed the [Set Up Your Environment](https://reactnative.dev/docs/set-up-your-environment) guide before proceeding. -## Getting Started +## Step 1: Start Metro -> **Note**: Make sure you have completed the [React Native Environment Setup](https://reactnative.dev/docs/environment-setup) guide before proceeding. +First, you will need to run **Metro**, the JavaScript build tool for React Native. -### Install Dependencies +To start the Metro dev server, run the following command from the root of your React Native project: -```bash -pnpm install +```sh +# Using npm +npm start + +# OR using Yarn +yarn start ``` -### Start Metro Bundler +## Step 2: Build and run your app + +With Metro running, open a new terminal window/pane from the root of your React Native project, and use one of the following commands to build and run your Android or iOS app: + +### Android -First, start Metro, the JavaScript bundler: +```sh +# Using npm +npm run android -```bash -pnpm start +# OR using Yarn +yarn android ``` -### Run on Android +### iOS -In a new terminal: +For iOS, remember to install CocoaPods dependencies (this only needs to be run on first clone or after updating native deps). -```bash -pnpm android +The first time you create a new project, run the Ruby bundler to install CocoaPods itself: + +```sh +bundle install ``` -### Run on iOS +Then, and every time you update your native dependencies, run: + +```sh +bundle exec pod install +``` -For iOS, you must install CocoaPods dependencies first (Mac only): +For more information, please visit [CocoaPods Getting Started guide](https://guides.cocoapods.org/using/getting-started.html). -```bash -cd ios && pod install && cd .. -pnpm ios +```sh +# Using npm +npm run ios + +# OR using Yarn +yarn ios ``` -## Architecture +If everything is set up correctly, you should see your new app running in the Android Emulator, iOS Simulator, or your connected device. + +This is one way to run your app — you can also build it directly from Android Studio or Xcode. + +## Step 3: Modify your app + +Now that you have successfully run the app, let's make changes! + +Open `App.tsx` in your text editor of choice and make some changes. When you save, your app will automatically update and reflect these changes — this is powered by [Fast Refresh](https://reactnative.dev/docs/fast-refresh). + +When you want to forcefully reload, for example to reset the state of your app, you can perform a full reload: + +- **Android**: Press the R key twice or select **"Reload"** from the **Dev Menu**, accessed via Ctrl + M (Windows/Linux) or Cmd ⌘ + M (macOS). +- **iOS**: Press R in iOS Simulator. + +## Congratulations! :tada: + +You've successfully run and modified your React Native App. :partying_face: + +### Now what? + +- If you want to add this new React Native code to an existing application, check out the [Integration guide](https://reactnative.dev/docs/integration-with-existing-apps). +- If you're curious to learn more about React Native, check out the [docs](https://reactnative.dev/docs/getting-started). + +# Troubleshooting + +If you're having issues getting the above steps to work, see the [Troubleshooting](https://reactnative.dev/docs/troubleshooting) page. + +# Learn More + +To learn more about React Native, take a look at the following resources: -- **Screens**: Located in `src/screens` -- **Navigation**: Managed via `src/navigation/MainTabs.tsx` -- **Context API**: Handles global authentication and token management -- **Theme**: Tokens are strictly defined in `src/theme/tokens.ts` +- [React Native Website](https://reactnative.dev) - learn more about React Native. +- [Getting Started](https://reactnative.dev/docs/environment-setup) - an **overview** of React Native and how setup your environment. +- [Learn the Basics](https://reactnative.dev/docs/getting-started) - a **guided tour** of the React Native **basics**. +- [Blog](https://reactnative.dev/blog) - read the latest official React Native **Blog** posts. +- [`@facebook/react-native`](https://github.com/facebook/react-native) - the Open Source; GitHub **repository** for React Native. diff --git a/apps/mobile/android/README.md b/apps/mobile/android/README.md new file mode 100644 index 00000000..90af1301 --- /dev/null +++ b/apps/mobile/android/README.md @@ -0,0 +1,111 @@ +# DevCard Android + +Native Android project for the DevCard React Native app. + +Use this folder for Android-specific Gradle builds, native configuration, emulator installs, and troubleshooting. For normal development, run commands from `apps/mobile` unless a command below says otherwise. + +## Quick Start + +Start Metro from `apps/mobile`: + +```cmd +npx react-native start --reset-cache +``` + +Open a second terminal and run the Android app: + +```cmd +cd /d D:\DC\apps\mobile +npx react-native run-android -- --active-arch-only +``` + +`--active-arch-only` builds only the connected emulator/device architecture, which is much faster during local development. + +## Requirements + +- Node.js `>= 22.11.0` +- Android Studio with Android SDK and emulator support +- Java version compatible with the Android Gradle Plugin used by this project +- npm dependencies installed from `apps/mobile` +- An Android emulator running, or a physical device connected with USB debugging enabled + +Install mobile dependencies from `apps/mobile`: + +```cmd +npm install --legacy-peer-deps +``` + +## Useful Gradle Commands + +Run these from `apps/mobile/android`. + +Build a debug APK: + +```cmd +gradlew.bat app:packageDebug -PreactNativeArchitectures=x86_64 +``` + +Install the debug build on a connected emulator/device: + +```cmd +gradlew.bat app:installDebug -PreactNativeArchitectures=x86_64 +``` + +Get detailed output for a failing build: + +```cmd +gradlew.bat app:packageDebug --stacktrace -PreactNativeArchitectures=x86_64 +``` + +## Architecture Builds + +`gradle.properties` currently sets: + +```properties +reactNativeArchitectures=x86_64 +``` + +This keeps Windows emulator builds smaller and faster. Override it when targeting another device architecture: + +```cmd +gradlew.bat app:packageDebug -PreactNativeArchitectures=arm64-v8a +``` + +Use all common Android ABIs only when needed: + +```cmd +gradlew.bat app:packageDebug -PreactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64 +``` + +## Troubleshooting + +### Metro Watches `D:\packages` + +If Metro reports `ENOENT` for `D:\packages`, check `apps/mobile/metro.config.js`. The monorepo root should resolve to `D:\DC`, not `D:\`. + +### Gradle Fails At `:app:packageDebug` + +Rerun the package task with `--stacktrace` so the real error appears: + +```cmd +gradlew.bat app:packageDebug --stacktrace -PreactNativeArchitectures=x86_64 +``` + +### Builds Are Slow + +Use one of these faster local options: + +- `npx react-native run-android -- --active-arch-only` +- `gradlew.bat app:packageDebug -PreactNativeArchitectures=x86_64` + +Avoid building every ABI unless you are preparing a broader test or release build. + +### Windows Long Path Errors + +If Windows reports paths longer than 260 characters, enable long paths from an Administrator PowerShell: + +```powershell +New-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" -Name "LongPathsEnabled" -Value 1 -PropertyType DWORD -Force +``` + +Restart the terminal after changing this setting. diff --git a/apps/mobile/android/app/src/main/AndroidManifest.xml b/apps/mobile/android/app/src/main/AndroidManifest.xml index e69de29b..fb78f397 100644 --- a/apps/mobile/android/app/src/main/AndroidManifest.xml +++ b/apps/mobile/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + diff --git a/apps/mobile/android/gradle.properties b/apps/mobile/android/gradle.properties index 183b46a8..45f5a026 100644 --- a/apps/mobile/android/gradle.properties +++ b/apps/mobile/android/gradle.properties @@ -25,14 +25,14 @@ android.useAndroidX=true # Use this property to specify which architecture you want to build. # You can also override it from the CLI using # ./gradlew -PreactNativeArchitectures=x86_64 -reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64 +reactNativeArchitectures=x86_64 # Use this property to enable support to the new architecture. # This will allow you to use TurboModules and the Fabric render in # your application. You should enable this flag either if you want # to write custom TurboModules/Fabric components OR use libraries that # are providing them. -newArchEnabled=false +newArchEnabled=true # Use this property to enable or disable the Hermes JS engine. # If set to false, you will be using JSC instead. diff --git a/apps/mobile/android/gradle/wrapper/gradle-wrapper.jar b/apps/mobile/android/gradle/wrapper/gradle-wrapper.jar index 8bdaf60c..61285a65 100644 Binary files a/apps/mobile/android/gradle/wrapper/gradle-wrapper.jar and b/apps/mobile/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/apps/mobile/android/gradle/wrapper/gradle-wrapper.properties b/apps/mobile/android/gradle/wrapper/gradle-wrapper.properties index 2a84e188..37f78a6a 100644 --- a/apps/mobile/android/gradle/wrapper/gradle-wrapper.properties +++ b/apps/mobile/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/apps/mobile/android/gradlew b/apps/mobile/android/gradlew index ef07e016..adff685a 100755 --- a/apps/mobile/android/gradlew +++ b/apps/mobile/android/gradlew @@ -114,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH="\\\"\\\"" # Determine the Java command to use to start the JVM. @@ -172,7 +171,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -212,7 +210,6 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" diff --git a/apps/mobile/android/gradlew.bat b/apps/mobile/android/gradlew.bat index 11bf1829..39baf4d6 100644 --- a/apps/mobile/android/gradlew.bat +++ b/apps/mobile/android/gradlew.bat @@ -75,11 +75,10 @@ goto fail :execute @rem Setup the command line -set CLASSPATH= @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell diff --git a/apps/mobile/android/settings.gradle b/apps/mobile/android/settings.gradle index 7a1c5313..678535d4 100644 --- a/apps/mobile/android/settings.gradle +++ b/apps/mobile/android/settings.gradle @@ -1,6 +1,6 @@ pluginManagement { includeBuild("../node_modules/@react-native/gradle-plugin") } plugins { id("com.facebook.react.settings") } extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autolinkLibrariesFromCommand() } -rootProject.name = 'com.devcard.app' +rootProject.name = 'DevCard' include ':app' includeBuild('../node_modules/@react-native/gradle-plugin') diff --git a/apps/mobile/ios/DevCard/LaunchScreen.storyboard b/apps/mobile/ios/DevCard/LaunchScreen.storyboard index 0a51e512..0db2bff1 100644 --- a/apps/mobile/ios/DevCard/LaunchScreen.storyboard +++ b/apps/mobile/ios/DevCard/LaunchScreen.storyboard @@ -16,7 +16,7 @@ -