diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9aead67 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +node_modules +dist + +# Local dev database +docker-compose.dev.yml + +# DB dumps +backups/ +*.dmp +*.dump +*.sql.gz + +# Env +.env +.env.* +!.env.example +!.env.test + +# OS +.DS_Store +Thumbs.db + +# Editor/tool configs +.vscode/ +.claude/ +CLAUDE.md +.mcp.json + +# Dev-server stdout captures +*-dev.log +*.log + +# One-off / local scripts +/scripts/ +backend/scripts/ diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 94658f3..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "workbench.browser.enableChatTools": true -} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..2b6638e --- /dev/null +++ b/README.md @@ -0,0 +1,138 @@ +
+ Hack Club flag +

Beest

+

The NestJS + SvelteKit + PostgreSQL codebase powering Beest, a hackathon in the Netherlands

+
+ +--- + +# Beest + +The Beest codebase is what runs on https://beest.hackclub.com. That website is the You Ship We Ship platform allowing participants to sign in, create and share projects, recieve feedback and earn prizes through the shop. + + +## Architecture + +This is a monorepo with two applications: + +| Layer | Stack | Role | +|-------|-------|------| +| **`backend/`** | NestJS 11, TypeORM, PostgreSQL | Single source of truth - all auth, business logic, and data access | +| **`frontend/`** | SvelteKit 2, Svelte 5 | Thin proxy - renders UI, sets cookies, forwards requests to the backend | + + + +## Development Setup + +```bash +git clone https://github.com/hackclub/beest +cd beest + +# Start the database +docker compose -f docker-compose.dev.yml up -d + +# Backend +cd backend +npm install +cp .env.example .env # fill in credentials +npm run migration:run +npm run start:dev # runs on :3001 + +# Frontend (in a second terminal) +cd frontend +npm install +npm run dev # runs on :5173 +``` + +## Environment Variables + +### Backend (`backend/.env`) + +```bash +# Airtable +AIRTABLE_API_KEY= +AIRTABLE_BASE_ID= +AIRTABLE_TABLE_NAME= + +# Hack Club Auth OAuth +CLIENT_ID= +CLIENT_SECRET= +REDIRECT_URI=http://localhost:5173/oauth/callback + +# JWT & encryption +JWT_SECRET= +DB_ENCRYPTION_KEY= # 32-byte hex string for AES-256-GCM + +# Hackatime OAuth +HACKATIME_CLIENT_ID= +HACKATIME_CLIENT_SECRET= +HACKATIME_REDIRECT_URI=http://localhost:5173/auth/hackatime/callback +HACKATIME_BASE_URL=https://hackatime.hackclub.com + +# Database +DATABASE_URL=postgresql://postgres:password@localhost:5432/postgres + +# Slack +SLACK_BOT_TOKEN= +``` + +### Frontend (`frontend/.env`) + +```bash +BACKEND_URL=http://localhost:3001 +``` + +## Deployment + +### Docker Compose + +```bash +docker compose up --build +``` + +### Dockerfile (standalone) + +Both `backend/` and `frontend/` have their own multi-stage Dockerfiles (Node 22 Alpine). Point `DATABASE_URL` at your PostgreSQL instance and set all backend env vars. + +| Service | Internal Port | +|---------|---------------| +| Frontend | 3000 | +| Backend | 3001 | +| PostgreSQL | 5432 | + +## API + +All endpoints live under the backend at `/api`. Auth-protected routes require a `Bearer` JWT in the `Authorization` header. + +| Endpoint | Method | Auth | Purpose | +|----------|--------|------|---------| +| `/api/health` | GET | — | Health check | +| `/api/auth/start` | POST | — | Begin OAuth flow | +| `/api/auth/handle-callback` | POST | — | Complete OAuth, issue tokens | +| `/api/auth/refresh` | POST | — | Rotate refresh token | +| `/api/auth/me` | GET | JWT | Current user claims | +| `/api/auth/logout` | POST | — | Invalidate session | +| `/api/auth/rsvp` | POST | JWT | RSVP using authenticated session | +| `/api/auth/scope` | GET | JWT | Check user permissions | +| `/api/rsvp` | POST | — | Submit an RSVP | +| `/api/hackatime/start` | POST | JWT | Begin Hackatime OAuth | +| `/api/hackatime/callback` | POST | JWT | Complete Hackatime OAuth | +| `/api/hackatime/projects` | GET | JWT | User's Hackatime project names | +| `/api/projects` | GET | JWT | List user's projects | +| `/api/projects` | POST | JWT | Create a project | +| `/api/projects/:id` | PATCH | JWT | Update a project | +| `/api/projects/:id` | DELETE | JWT | Delete a project | +| `/api/projects/hours` | GET | JWT | Hackatime hours breakdown | +| `/api/leaderboard` | GET | JWT | Top 10 users by approved hours | +| `/api/onboarding/status` | GET | JWT | Onboarding step completion | +| `/api/onboarding/two-emails` | POST | JWT | Confirm different Slack email | +| `/api/onboarding/sticker-link` | GET | JWT | User's unique sticker form link | +| `/api/audit-log` | GET | JWT | User's audit log entries | +| `/api/admin/users` | GET | Admin | List all users | +| `/api/admin/users/:id` | GET | Admin | Get specific user | +| `/api/admin/users/:id/ban` | POST | Admin | Ban a user | +| `/api/admin/users/:id/perms` | PATCH | Admin | Update user permissions | + +--- + +Made with <3 by [euan](https://github.com/EDRipper) , give it a ⭐ \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..0f027a6 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,41 @@ +AIRTABLE_API_KEY= +AIRTABLE_BASE_ID= +AIRTABLE_TABLE_NAME= +PORT=3001 + +# Hack Club Auth +CLIENT_ID= +CLIENT_SECRET= +JWT_SECRET= +REDIRECT_URI=http://localhost:5173/oauth/callback + +# Hackatime OAuth +HACKATIME_CLIENT_ID= +HACKATIME_CLIENT_SECRET= +HACKATIME_REDIRECT_URI=http://localhost:5173/auth/hackatime/callback +HACKATIME_BASE_URL=https://hackatime.hackclub.com +HACKATIME_ADMIN_API_KEY= + +# Database (use container name in prod, public hostname in staging) +DATABASE_URL=postgresql://postgres:password@localhost:5432/postgres +# 32-byte hex key for AES-256-GCM column encryption (generate: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))") +DB_ENCRYPTION_KEY= + +# Lapse (https://github.com/hackclub/lapse) — program key for server-to-server +# lookups so reviewers can watch a builder's timelapses inline. Leave blank to +# disable; the feature degrades silently when unset. +LAPSE_BASE_URL=https://lapse.hackclub.com +LAPSE_PROGRAM_KEY= + +# HCB card grants (https://hcb.hackclub.com) — confidential OAuth app, scopes "read write". +# I'm sorry you can't really recreate these unless you work at HQ. Self host hcb? +HCB_BASE_URL=https://hcb.hackclub.com +HCB_CLIENT_ID= +HCB_CLIENT_SECRET= +HCB_REDIRECT_URI=http://localhost:5173/oauth/hcb/callback +# Public ID or slug of the single org that issues grants +HCB_ORG_ID= +# Suggested grant amount only (NOT a cap): the popup prefills amount as +# pipes_spent * HCB_CENTS_PER_PIPE, but admins can override to any value. +# Cents per pipe — 500 = $5 per pipe. Leave blank to prefill nothing. +HCB_CENTS_PER_PIPE= diff --git a/backend/.prettierrc b/backend/.prettierrc new file mode 100644 index 0000000..a20502b --- /dev/null +++ b/backend/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "trailingComma": "all" +} diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..ad7fdcb --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,17 @@ +FROM node:22-alpine AS build +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM node:22-alpine +RUN apk add --no-cache curl +WORKDIR /app +COPY --from=build /app/dist ./dist +COPY --from=build /app/package*.json ./ +RUN npm ci --omit=dev +EXPOSE 3001 +HEALTHCHECK --interval=10s --timeout=3s --start-period=30s --retries=3 \ + CMD curl -f http://localhost:3001/api/health || exit 1 +CMD ["node", "dist/main"] diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..8f0f65f --- /dev/null +++ b/backend/README.md @@ -0,0 +1,98 @@ +

+ Nest Logo +

+ +[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 +[circleci-url]: https://circleci.com/gh/nestjs/nest + +

A progressive Node.js framework for building efficient and scalable server-side applications.

+

+NPM Version +Package License +NPM Downloads +CircleCI +Discord +Backers on Open Collective +Sponsors on Open Collective + Donate us + Support us + Follow us on Twitter +

+ + +## Description + +[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. + +## Project setup + +```bash +$ npm install +``` + +## Compile and run the project + +```bash +# development +$ npm run start + +# watch mode +$ npm run start:dev + +# production mode +$ npm run start:prod +``` + +## Run tests + +```bash +# unit tests +$ npm run test + +# e2e tests +$ npm run test:e2e + +# test coverage +$ npm run test:cov +``` + +## Deployment + +When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information. + +If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps: + +```bash +$ npm install -g @nestjs/mau +$ mau deploy +``` + +With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure. + +## Resources + +Check out a few resources that may come in handy when working with NestJS: + +- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework. +- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy). +- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/). +- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks. +- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com). +- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com). +- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs). +- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com). + +## Support + +Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). + +## Stay in touch + +- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec) +- Website - [https://nestjs.com](https://nestjs.com/) +- Twitter - [@nestframework](https://twitter.com/nestframework) + +## License + +Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE). diff --git a/backend/eslint.config.mjs b/backend/eslint.config.mjs new file mode 100644 index 0000000..4e9f827 --- /dev/null +++ b/backend/eslint.config.mjs @@ -0,0 +1,35 @@ +// @ts-check +import eslint from '@eslint/js'; +import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { + ignores: ['eslint.config.mjs'], + }, + eslint.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, + eslintPluginPrettierRecommended, + { + languageOptions: { + globals: { + ...globals.node, + ...globals.jest, + }, + sourceType: 'commonjs', + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + { + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-floating-promises': 'warn', + '@typescript-eslint/no-unsafe-argument': 'warn', + "prettier/prettier": ["error", { endOfLine: "auto" }], + }, + }, +); diff --git a/backend/nest-cli.json b/backend/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/backend/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..50e4e1c --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,10394 @@ +{ + "name": "backend", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "backend", + "version": "0.0.1", + "license": "UNLICENSED", + "dependencies": { + "@nestjs/common": "^11.0.1", + "@nestjs/config": "^4.0.4", + "@nestjs/core": "^11.1.18", + "@nestjs/jwt": "^11.0.2", + "@nestjs/platform-express": "^11.1.18", + "@nestjs/schedule": "^6.1.3", + "@nestjs/throttler": "^6.5.0", + "@nestjs/typeorm": "^11.0.0", + "helmet": "^8.1.0", + "jsonwebtoken": "^9.0.3", + "pg": "^8.20.0", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1", + "typeorm": "^1.0.0" + }, + "devDependencies": { + "@eslint/eslintrc": "^3.2.0", + "@eslint/js": "^9.18.0", + "@nestjs/cli": "^11.0.0", + "@nestjs/schematics": "^11.0.0", + "@nestjs/testing": "^11.0.1", + "@types/express": "^5.0.0", + "@types/jest": "^30.0.0", + "@types/jsonwebtoken": "^9.0.10", + "@types/node": "^22.10.7", + "@types/supertest": "^6.0.2", + "eslint": "^9.18.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-prettier": "^5.2.2", + "globals": "^16.0.0", + "jest": "^30.0.0", + "prettier": "^3.4.2", + "source-map-support": "^0.5.21", + "supertest": "^7.0.0", + "ts-jest": "^29.2.5", + "ts-loader": "^9.5.2", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.7.3", + "typescript-eslint": "^8.20.0" + } + }, + "node_modules/@angular-devkit/core": { + "version": "19.2.24", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.24.tgz", + "integrity": "sha512-Kd49warf6U/EyWe5BszF/eebN3zQ3bk7tgfEljAw8q/rX95UUtriJubWvp6pgzHfzBA4jwq8f+QiNZB8eBEXPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.18.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.4", + "rxjs": "7.8.1", + "source-map": "0.7.4" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^4.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/core/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "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/@angular-devkit/core/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/@angular-devkit/core/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@angular-devkit/schematics": { + "version": "19.2.24", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.24.tgz", + "integrity": "sha512-lnw+ZM1Io+cJAkReC0NPDjqObL8NtKzKIkdgEEKC8CUmkhurYhedbicN8Y8NYHgG1uLd2GozW3+/QqPRZaN+Lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.24", + "jsonc-parser": "3.3.1", + "magic-string": "0.30.17", + "ora": "5.4.1", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/schematics-cli": { + "version": "19.2.24", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics-cli/-/schematics-cli-19.2.24.tgz", + "integrity": "sha512-bsStZQG67J1HBqTmWxtIcobvgrn32L4UOdL7hGyOru5VxDWPNA8pRnDYavT3hnJeBkJYPoQIw8u7Dm0ecoQprw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.24", + "@angular-devkit/schematics": "19.2.24", + "@inquirer/prompts": "7.3.2", + "ansi-colors": "4.1.3", + "symbol-observable": "4.0.0", + "yargs-parser": "21.1.1" + }, + "bin": { + "schematics": "bin/schematics.js" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/schematics-cli/node_modules/@inquirer/prompts": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.3.2.tgz", + "integrity": "sha512-G1ytyOoHh5BphmEBxSwALin3n1KGNYB6yImbICcRQdzXfOGbuJ9Jske/Of5Sebk339NSGGNfUshnzK8YWkTPsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.1.2", + "@inquirer/confirm": "^5.1.6", + "@inquirer/editor": "^4.2.7", + "@inquirer/expand": "^4.0.9", + "@inquirer/input": "^4.1.6", + "@inquirer/number": "^3.0.9", + "@inquirer/password": "^4.0.9", + "@inquirer/rawlist": "^4.0.9", + "@inquirer/search": "^3.0.9", + "@inquirer/select": "^4.0.9" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/schematics/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "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.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.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/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.3.2.tgz", + "integrity": "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor": { + "version": "4.2.23", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.23.tgz", + "integrity": "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/external-editor": "^1.0.3", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.23.tgz", + "integrity": "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", + "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/input": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.3.1.tgz", + "integrity": "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.23.tgz", + "integrity": "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.23.tgz", + "integrity": "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.10.1.tgz", + "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.3.2", + "@inquirer/confirm": "^5.1.21", + "@inquirer/editor": "^4.2.23", + "@inquirer/expand": "^4.0.23", + "@inquirer/input": "^4.3.1", + "@inquirer/number": "^3.0.23", + "@inquirer/password": "^4.0.23", + "@inquirer/rawlist": "^4.1.11", + "@inquirer/search": "^3.2.2", + "@inquirer/select": "^4.4.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.11.tgz", + "integrity": "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.2.2.tgz", + "integrity": "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.4.2.tgz", + "integrity": "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.3.0.tgz", + "integrity": "sha512-PAwCvFJ4696XP2qZj+LAn1BWjZaJ6RjG6c7/lkMaUJnkyMS34ucuIsfqYvfskVNvUI27R/u4P1HMYFnlVXG/Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.3.0", + "@types/node": "*", + "chalk": "^4.1.2", + "jest-message-util": "30.3.0", + "jest-util": "30.3.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/core": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.3.0.tgz", + "integrity": "sha512-U5mVPsBxLSO6xYbf+tgkymLx+iAhvZX43/xI1+ej2ZOPnPdkdO1CzDmFKh2mZBn2s4XZixszHeQnzp1gm/DIxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.3.0", + "@jest/pattern": "30.0.1", + "@jest/reporters": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-changed-files": "30.3.0", + "jest-config": "30.3.0", + "jest-haste-map": "30.3.0", + "jest-message-util": "30.3.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.3.0", + "jest-resolve-dependencies": "30.3.0", + "jest-runner": "30.3.0", + "jest-runtime": "30.3.0", + "jest-snapshot": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", + "jest-watcher": "30.3.0", + "pretty-format": "30.3.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/diff-sequences": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.3.0.tgz", + "integrity": "sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.3.0.tgz", + "integrity": "sha512-SlLSF4Be735yQXyh2+mctBOzNDx5s5uLv88/j8Qn1wH679PDcwy67+YdADn8NJnGjzlXtN62asGH/T4vWOkfaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "jest-mock": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.3.0.tgz", + "integrity": "sha512-76Nlh4xJxk2D/9URCn3wFi98d2hb19uWE1idLsTt2ywhvdOldbw3S570hBgn25P4ICUZ/cBjybrBex2g17IDbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "30.3.0", + "jest-snapshot": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.3.0.tgz", + "integrity": "sha512-j0+W5iQQ8hBh7tHZkTQv3q2Fh/M7Je72cIsYqC4OaktgtO7v1So9UTjp6uPBHIaB6beoF/RRsCgMJKvti0wADA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.3.0.tgz", + "integrity": "sha512-WUQDs8SOP9URStX1DzhD425CqbN/HxUYCTwVrT8sTVBfMvFqYt/s61EK5T05qnHu0po6RitXIvP9otZxYDzTGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.3.0", + "@sinonjs/fake-timers": "^15.0.0", + "@types/node": "*", + "jest-message-util": "30.3.0", + "jest-mock": "30.3.0", + "jest-util": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/get-type": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.3.0.tgz", + "integrity": "sha512-+owLCBBdfpgL3HU+BD5etr1SvbXpSitJK0is1kiYjJxAAJggYMRQz5hSdd5pq1sSggfxPbw2ld71pt4x5wwViA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.3.0", + "@jest/expect": "30.3.0", + "@jest/types": "30.3.0", + "jest-mock": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", + "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.3.0.tgz", + "integrity": "sha512-a09z89S+PkQnL055bVj8+pe2Caed2PBOaczHcXCykW5ngxX9EWx/1uAwncxc/HiU0oZqfwseMjyhxgRjS49qPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", + "@jridgewell/trace-mapping": "^0.3.25", + "@types/node": "*", + "chalk": "^4.1.2", + "collect-v8-coverage": "^1.0.2", + "exit-x": "^0.2.2", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^5.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "30.3.0", + "jest-util": "30.3.0", + "jest-worker": "30.3.0", + "slash": "^3.0.0", + "string-length": "^4.0.2", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/reporters/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@jest/reporters/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "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", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@jest/reporters/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@jest/reporters/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@jest/reporters/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/snapshot-utils": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.3.0.tgz", + "integrity": "sha512-ORbRN9sf5PP82v3FXNSwmO1OTDR2vzR2YTaR+E3VkSBZ8zadQE6IqYdYEeFH1NIkeB2HIGdF02dapb6K0Mj05g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.3.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "natural-compare": "^1.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", + "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "callsites": "^3.1.0", + "graceful-fs": "^4.2.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.3.0.tgz", + "integrity": "sha512-e/52nJGuD74AKTSe0P4y5wFRlaXP0qmrS17rqOMHeSwm278VyNyXE3gFO/4DTGF9w+65ra3lo3VKj0LBrzmgdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.3.0", + "@jest/types": "30.3.0", + "@types/istanbul-lib-coverage": "^2.0.6", + "collect-v8-coverage": "^1.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.3.0.tgz", + "integrity": "sha512-dgbWy9b8QDlQeRZcv7LNF+/jFiiYHTKho1xirauZ7kVwY7avjFF6uTT0RqlgudB5OuIPagFdVtfFMosjVbk1eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.3.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.3.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.3.0.tgz", + "integrity": "sha512-TLKY33fSLVd/lKB2YI1pH69ijyUblO/BQvCj566YvnwuzoTNr648iE0j22vRvVNk2HsPwByPxATg3MleS3gf5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/types": "30.3.0", + "@jridgewell/trace-mapping": "^0.3.25", + "babel-plugin-istanbul": "^7.0.1", + "chalk": "^4.1.2", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.3.0", + "jest-regex-util": "30.0.1", + "jest-util": "30.3.0", + "pirates": "^4.0.7", + "slash": "^3.0.0", + "write-file-atomic": "^5.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/types": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.3.0.tgz", + "integrity": "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "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==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lukeed/csprng": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", + "integrity": "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@nestjs/cli": { + "version": "11.0.21", + "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-11.0.21.tgz", + "integrity": "sha512-F8mV0Sj/zVEouzR3NxBuJy08YHTUOmC5Xdcx3qIIaJWzrm8Vw86CHkhkaPBJ5ewRMHPDCShPmhsfwhpCcjts3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.24", + "@angular-devkit/schematics": "19.2.24", + "@angular-devkit/schematics-cli": "19.2.24", + "@inquirer/prompts": "7.10.1", + "@nestjs/schematics": "^11.0.1", + "ansis": "4.2.0", + "chokidar": "4.0.3", + "cli-table3": "0.6.5", + "commander": "4.1.1", + "fork-ts-checker-webpack-plugin": "9.1.0", + "glob": "13.0.6", + "node-emoji": "1.11.0", + "ora": "5.4.1", + "tsconfig-paths": "4.2.0", + "tsconfig-paths-webpack-plugin": "4.2.0", + "typescript": "5.9.3", + "webpack": "5.106.0", + "webpack-node-externals": "3.0.0" + }, + "bin": { + "nest": "bin/nest.js" + }, + "engines": { + "node": ">= 20.11" + }, + "peerDependencies": { + "@swc/cli": "^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 || ^0.8.0", + "@swc/core": "^1.3.62" + }, + "peerDependenciesMeta": { + "@swc/cli": { + "optional": true + }, + "@swc/core": { + "optional": true + } + } + }, + "node_modules/@nestjs/common": { + "version": "11.1.17", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.17.tgz", + "integrity": "sha512-hLODw5Abp8OQgA+mUO4tHou4krKgDtUcM9j5Ihxncst9XeyxYBTt2bwZm4e4EQr5E352S4Fyy6V3iFx9ggxKAg==", + "license": "MIT", + "dependencies": { + "file-type": "21.3.2", + "iterare": "1.2.1", + "load-esm": "1.0.3", + "tslib": "2.8.1", + "uid": "2.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "class-transformer": ">=0.4.1", + "class-validator": ">=0.13.2", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, + "node_modules/@nestjs/config": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-4.0.4.tgz", + "integrity": "sha512-CJPjNitr0bAufSEnRe2N+JbnVmMmDoo6hvKCPzXgZoGwJSmp/dZPk9f/RMbuD/+Q1ZJPjwsRpq0vxna++Knwow==", + "license": "MIT", + "dependencies": { + "dotenv": "17.4.1", + "dotenv-expand": "12.0.3", + "lodash": "4.18.1" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "rxjs": "^7.1.0" + } + }, + "node_modules/@nestjs/core": { + "version": "11.1.18", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.18.tgz", + "integrity": "sha512-wR3DtGyk/LUAiPtbXDuWJJwVkWElKBY0sqnTzf9d4uM3+X18FRZhK7WFc47czsIGOdWuRsMeLYV+1Z9dO4zDEQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@nuxt/opencollective": "0.4.1", + "fast-safe-stringify": "2.1.1", + "iterare": "1.2.1", + "path-to-regexp": "8.4.2", + "tslib": "2.8.1", + "uid": "2.0.2" + }, + "engines": { + "node": ">= 20" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/microservices": "^11.0.0", + "@nestjs/platform-express": "^11.0.0", + "@nestjs/websockets": "^11.0.0", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "@nestjs/microservices": { + "optional": true + }, + "@nestjs/platform-express": { + "optional": true + }, + "@nestjs/websockets": { + "optional": true + } + } + }, + "node_modules/@nestjs/jwt": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-11.0.2.tgz", + "integrity": "sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "9.0.10", + "jsonwebtoken": "9.0.3" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0" + } + }, + "node_modules/@nestjs/platform-express": { + "version": "11.1.18", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.18.tgz", + "integrity": "sha512-s6GdHMTa3qx0fJewR74Xa30ysPHfBEqxIwZ7BGSTLoAEQ1vTP24urNl+b6+s49NFLEIOyeNho5fN/9/I17QlOw==", + "license": "MIT", + "dependencies": { + "cors": "2.8.6", + "express": "5.2.1", + "multer": "2.1.1", + "path-to-regexp": "8.4.2", + "tslib": "2.8.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0" + } + }, + "node_modules/@nestjs/schedule": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-6.1.3.tgz", + "integrity": "sha512-RflMFOpR16Dwd1jAUbeB4mfGTCh65fvEdL4mSjQPJChpkRGRjIXjb+6YQcK2faQrVT60c9DmLmoVR7/ONCtuYQ==", + "license": "MIT", + "dependencies": { + "cron": "4.4.0" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0" + } + }, + "node_modules/@nestjs/schematics": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz", + "integrity": "sha512-lVxGZ46tcdItFMoXr6vyKWlnOsm1SZm/GUqAEDvy2RL4Q4O+3bkziAhrO7Y8JLssFUUvNFEGqAizI52WAxhjDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.24", + "@angular-devkit/schematics": "19.2.24", + "comment-json": "5.0.0", + "jsonc-parser": "3.3.1", + "pluralize": "8.0.0" + }, + "peerDependencies": { + "prettier": "^3.0.0", + "typescript": ">=4.8.2" + }, + "peerDependenciesMeta": { + "prettier": { + "optional": true + } + } + }, + "node_modules/@nestjs/testing": { + "version": "11.1.17", + "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.1.17.tgz", + "integrity": "sha512-lNffw+z+2USewmw4W0tsK+Rq94A2N4PiHbcqoRUu5y8fnqxQeIWGHhjo5BFCqj7eivqJBhT7WdRydxVq4rAHzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/microservices": "^11.0.0", + "@nestjs/platform-express": "^11.0.0" + }, + "peerDependenciesMeta": { + "@nestjs/microservices": { + "optional": true + }, + "@nestjs/platform-express": { + "optional": true + } + } + }, + "node_modules/@nestjs/throttler": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.5.0.tgz", + "integrity": "sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "reflect-metadata": "^0.1.13 || ^0.2.0" + } + }, + "node_modules/@nestjs/typeorm": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-11.0.0.tgz", + "integrity": "sha512-SOeUQl70Lb2OfhGkvnh4KXWlsd+zA08RuuQgT7kKbzivngxzSo1Oc7Usu5VxCxACQC9wc2l9esOHILSJeK7rJA==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0", + "reflect-metadata": "^0.1.13 || ^0.2.0", + "rxjs": "^7.2.0", + "typeorm": "^0.3.0" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nuxt/opencollective": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@nuxt/opencollective/-/opencollective-0.4.1.tgz", + "integrity": "sha512-GXD3wy50qYbxCJ652bDrDzgMr3NFEkIS374+IgFQKkCvk9yiYcLvX2XDYr7UyQxf4wK0e+yqDYRubZ0DtOxnmQ==", + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + }, + "bin": { + "opencollective": "bin/opencollective.js" + }, + "engines": { + "node": "^14.18.0 || >=16.10.0", + "npm": ">=5.10.0" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.34.48", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.1.1.tgz", + "integrity": "sha512-cO5W33JgAPbOh07tvZjUOJ7oWhtaqGHiZw+11DPbyqh2kHTBc3eF/CjJDeQ4205RLQsX6rxCuYOroFQwl7JDRw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@sqltools/formatter": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz", + "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==", + "license": "MIT" + }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^30.0.0", + "pretty-format": "^30.0.0" + } + }, + "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/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/luxon": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.1.tgz", + "integrity": "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==", + "license": "MIT" + }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/superagent": { + "version": "8.1.9", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", + "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz", + "integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz", + "integrity": "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/type-utils": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.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.57.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.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.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.2.tgz", + "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "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.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz", + "integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.57.2", + "@typescript-eslint/types": "^8.57.2", + "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.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz", + "integrity": "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2" + }, + "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.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz", + "integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==", + "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.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz", + "integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.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.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", + "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==", + "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.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz", + "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.57.2", + "@typescript-eslint/tsconfig-utils": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.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.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/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==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz", + "integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2" + }, + "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.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz", + "integrity": "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.2", + "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/@typescript-eslint/visitor-keys/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/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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==", + "devOptional": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.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/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz", + "integrity": "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==", + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-timsort": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", + "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/babel-jest": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.3.0.tgz", + "integrity": "sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "30.3.0", + "@types/babel__core": "^7.20.5", + "babel-plugin-istanbul": "^7.0.1", + "babel-preset-jest": "30.3.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", + "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", + "dev": true, + "license": "BSD-3-Clause", + "workspaces": [ + "test/babel-8" + ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-instrument": "^6.0.2", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.3.0.tgz", + "integrity": "sha512-+TRkByhsws6sfPjVaitzadk1I0F5sPvOVUH5tyTSzhePpsGIVrdeunHSw/C36QeocS95OOk8lunc4rlu5Anwsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/babel__core": "^7.20.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.3.0.tgz", + "integrity": "sha512-6ZcUbWHC+dMz2vfzdNwi87Z1gQsLNK2uLuK1Q89R11xdvejcivlYYwDlEv0FHX3VwEXpbBQ9uufB/MUNpZGfhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "30.3.0", + "babel-preset-current-node-syntax": "^1.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-beta.1" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "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/baseline-browser-mapping": { + "version": "2.10.10", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.10.tgz", + "integrity": "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "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.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "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", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001781", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", + "integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==", + "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/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/chardet": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", + "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "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/cjs-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-table3": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", + "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/comment-json": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-5.0.0.tgz", + "integrity": "sha512-uiqLcOiVDJtBP8WGkZHEP+FZIhTzP1dxvn59EfoYUi9gqupjrBWVQkO2atDrbnKPwLeotFYDsuNb26uBMqB+hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-timsort": "^1.0.3", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/cron": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/cron/-/cron-4.4.0.tgz", + "integrity": "sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==", + "license": "MIT", + "dependencies": { + "@types/luxon": "~3.7.0", + "luxon": "~3.7.0" + }, + "engines": { + "node": ">=18.x" + }, + "funding": { + "type": "ko-fi", + "url": "https://ko-fi.com/intcreator" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, + "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/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "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": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "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/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dotenv": { + "version": "17.4.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz", + "integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.3.tgz", + "integrity": "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==", + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand/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/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "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/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.325", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.325.tgz", + "integrity": "sha512-PwfIw7WQSt3xX7yOf5OE/unLzsK9CaN2f/FvV3WjPR1Knoc1T9vePRVV4W1EM301JzzysK51K7FNKcusCr0zYA==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "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": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@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", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.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", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.5.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz", + "integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.1", + "synckit": "^0.11.12" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "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/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/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/exit-x": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", + "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.3.0.tgz", + "integrity": "sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.3.0", + "@jest/get-type": "30.1.0", + "jest-matcher-utils": "30.3.0", + "jest-message-util": "30.3.0", + "jest-mock": "30.3.0", + "jest-util": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true, + "license": "Apache-2.0" + }, + "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-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-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==", + "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==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "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/file-type": { + "version": "21.3.2", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.2.tgz", + "integrity": "sha512-DLkUvGwep3poOV2wpzbHCOnSKGk1LzyXTv+aHFgN2VFl96wnp8YA9YjO2qPzg5PuL8q/SW9Pdi6WTkYOIh995w==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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/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==", + "dev": true, + "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/fork-ts-checker-webpack-plugin": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-9.1.0.tgz", + "integrity": "sha512-mpafl89VFPJmhnJ1ssH+8wmM2b50n+Rew5x42NeI2U78aRWgtkEtGmctp7iT16UjquJTjorEmIfESj3DxdW84Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.16.7", + "chalk": "^4.1.2", + "chokidar": "^4.0.1", + "cosmiconfig": "^8.2.0", + "deepmerge": "^4.2.2", + "fs-extra": "^10.0.0", + "memfs": "^3.4.1", + "minimatch": "^3.0.4", + "node-abort-controller": "^3.0.1", + "schema-utils": "^3.1.1", + "semver": "^7.3.5", + "tapable": "^2.2.1" + }, + "engines": { + "node": ">=14.21.3" + }, + "peerDependencies": { + "typescript": ">3.6.0", + "webpack": "^5.11.0" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs-monkey": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.1.0.tgz", + "integrity": "sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==", + "dev": true, + "license": "Unlicense" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "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/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.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-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 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/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/glob/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==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "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/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/handlebars/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "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/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "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": "BSD-3-Clause" + }, + "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/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "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/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/iterare": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iterare/-/iterare-1.2.1.tgz", + "integrity": "sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==", + "license": "ISC", + "engines": { + "node": ">=6" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jest": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.3.0.tgz", + "integrity": "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.3.0", + "@jest/types": "30.3.0", + "import-local": "^3.2.0", + "jest-cli": "30.3.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.3.0.tgz", + "integrity": "sha512-B/7Cny6cV5At6M25EWDgf9S617lHivamL8vl6KEpJqkStauzcG4e+WPfDgMMF+H4FVH4A2PLRyvgDJan4441QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.1.1", + "jest-util": "30.3.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-circus": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.3.0.tgz", + "integrity": "sha512-PyXq5szeSfR/4f1lYqCmmQjh0vqDkURUYi9N6whnHjlRz4IUQfMcXkGLeEoiJtxtyPqgUaUUfyQlApXWBSN1RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.3.0", + "@jest/expect": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "chalk": "^4.1.2", + "co": "^4.6.0", + "dedent": "^1.6.0", + "is-generator-fn": "^2.1.0", + "jest-each": "30.3.0", + "jest-matcher-utils": "30.3.0", + "jest-message-util": "30.3.0", + "jest-runtime": "30.3.0", + "jest-snapshot": "30.3.0", + "jest-util": "30.3.0", + "p-limit": "^3.1.0", + "pretty-format": "30.3.0", + "pure-rand": "^7.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-cli": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.3.0.tgz", + "integrity": "sha512-l6Tqx+j1fDXJEW5bqYykDQQ7mQg+9mhWXtnj+tQZrTWYHyHoi6Be8HPumDSA+UiX2/2buEgjA58iJzdj146uCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/types": "30.3.0", + "chalk": "^4.1.2", + "exit-x": "^0.2.2", + "import-local": "^3.2.0", + "jest-config": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", + "yargs": "^17.7.2" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.3.0.tgz", + "integrity": "sha512-WPMAkMAtNDY9P/oKObtsRG/6KTrhtgPJoBTmk20uDn4Uy6/3EJnnaZJre/FMT1KVRx8cve1r7/FlMIOfRVWL4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/get-type": "30.1.0", + "@jest/pattern": "30.0.1", + "@jest/test-sequencer": "30.3.0", + "@jest/types": "30.3.0", + "babel-jest": "30.3.0", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "deepmerge": "^4.3.1", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "jest-circus": "30.3.0", + "jest-docblock": "30.2.0", + "jest-environment-node": "30.3.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.3.0", + "jest-runner": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", + "parse-json": "^5.2.0", + "pretty-format": "30.3.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "esbuild-register": ">=3.4.0", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "esbuild-register": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-config/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/jest-config/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "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", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-config/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jest-config/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-config/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-diff": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.3.0.tgz", + "integrity": "sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.3.0", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.2.0.tgz", + "integrity": "sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-each": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.3.0.tgz", + "integrity": "sha512-V8eMndg/aZ+3LnCJgSm13IxS5XSBM22QSZc9BtPK8Dek6pm+hfUNfwBdvsB3d342bo1q7wnSkC38zjX259qZNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.3.0", + "chalk": "^4.1.2", + "jest-util": "30.3.0", + "pretty-format": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.3.0.tgz", + "integrity": "sha512-4i6HItw/JSiJVsC5q0hnKIe/hbYfZLVG9YJ/0pU9Hz2n/9qZe3Rhn5s5CUZA5ORZlcdT/vmAXRMyONXJwPrmYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.3.0", + "@jest/fake-timers": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "jest-mock": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.3.0.tgz", + "integrity": "sha512-mMi2oqG4KRU0R9QEtscl87JzMXfUhbKaFqOxmjb2CKcbHcUGFrJCBWHmnTiUqi6JcnzoBlO4rWfpdl2k/RfLCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.3.0", + "@types/node": "*", + "anymatch": "^3.1.3", + "fb-watchman": "^2.0.2", + "graceful-fs": "^4.2.11", + "jest-regex-util": "30.0.1", + "jest-util": "30.3.0", + "jest-worker": "30.3.0", + "picomatch": "^4.0.3", + "walker": "^1.0.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.3" + } + }, + "node_modules/jest-leak-detector": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.3.0.tgz", + "integrity": "sha512-cuKmUUGIjfXZAiGJ7TbEMx0bcqNdPPI6P1V+7aF+m/FUJqFDxkFR4JqkTu8ZOiU5AaX/x0hZ20KaaIPXQzbMGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "pretty-format": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.3.0.tgz", + "integrity": "sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.3.0", + "pretty-format": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.3.0.tgz", + "integrity": "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.3.0", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3", + "pretty-format": "30.3.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-mock": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.3.0.tgz", + "integrity": "sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.3.0", + "@types/node": "*", + "jest-util": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", + "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.3.0.tgz", + "integrity": "sha512-NRtTAHQlpd15F9rUR36jqwelbrDV/dY4vzNte3S2kxCKUJRYNd5/6nTSbYiak1VX5g8IoFF23Uj5TURkUW8O5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.3.0", + "jest-pnp-resolver": "^1.2.3", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", + "slash": "^3.0.0", + "unrs-resolver": "^1.7.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.3.0.tgz", + "integrity": "sha512-9ev8s3YN6Hsyz9LV75XUwkCVFlwPbaFn6Wp75qnI0wzAINYWY8Fb3+6y59Rwd3QaS3kKXffHXsZMziMavfz/nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "30.0.1", + "jest-snapshot": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runner": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.3.0.tgz", + "integrity": "sha512-gDv6C9LGKWDPLia9TSzZwf4h3kMQCqyTpq+95PODnTRDO0g9os48XIYYkS6D236vjpBir2fF63YmJFtqkS5Duw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.3.0", + "@jest/environment": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-docblock": "30.2.0", + "jest-environment-node": "30.3.0", + "jest-haste-map": "30.3.0", + "jest-leak-detector": "30.3.0", + "jest-message-util": "30.3.0", + "jest-resolve": "30.3.0", + "jest-runtime": "30.3.0", + "jest-util": "30.3.0", + "jest-watcher": "30.3.0", + "jest-worker": "30.3.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runner/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jest-runner/node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/jest-runtime": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.3.0.tgz", + "integrity": "sha512-CgC+hIBJbuh78HEffkhNKcbXAytQViplcl8xupqeIWyKQF50kCQA8J7GeJCkjisC6hpnC9Muf8jV5RdtdFbGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.3.0", + "@jest/fake-timers": "30.3.0", + "@jest/globals": "30.3.0", + "@jest/source-map": "30.0.1", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "chalk": "^4.1.2", + "cjs-module-lexer": "^2.1.0", + "collect-v8-coverage": "^1.0.2", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.3.0", + "jest-message-util": "30.3.0", + "jest-mock": "30.3.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.3.0", + "jest-snapshot": "30.3.0", + "jest-util": "30.3.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runtime/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/jest-runtime/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "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", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-runtime/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jest-runtime/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-runtime/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-snapshot": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.3.0.tgz", + "integrity": "sha512-f14c7atpb4O2DeNhwcvS810Y63wEn8O1HqK/luJ4F6M4NjvxmAKQwBUWjbExUtMxWJQ0wVgmCKymeJK6NZMnfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@babel/generator": "^7.27.5", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/types": "^7.27.3", + "@jest/expect-utils": "30.3.0", + "@jest/get-type": "30.1.0", + "@jest/snapshot-utils": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", + "babel-preset-current-node-syntax": "^1.2.0", + "chalk": "^4.1.2", + "expect": "30.3.0", + "graceful-fs": "^4.2.11", + "jest-diff": "30.3.0", + "jest-matcher-utils": "30.3.0", + "jest-message-util": "30.3.0", + "jest-util": "30.3.0", + "pretty-format": "30.3.0", + "semver": "^7.7.2", + "synckit": "^0.11.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-util": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", + "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.3.0", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-validate": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.3.0.tgz", + "integrity": "sha512-I/xzC8h5G+SHCb2P2gWkJYrNiTbeL47KvKeW5EzplkyxzBRBw1ssSHlI/jXec0ukH2q7x2zAWQm7015iusg62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.3.0", + "camelcase": "^6.3.0", + "chalk": "^4.1.2", + "leven": "^3.1.0", + "pretty-format": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.3.0.tgz", + "integrity": "sha512-PJ1d9ThtTR8aMiBWUdcownq9mDdLXsQzJayTk4kmaBRHKvwNQn+ANveuhEBUyNI2hR1TVhvQ8D5kHubbzBHR/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "jest-util": "30.3.0", + "string-length": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.3.0.tgz", + "integrity": "sha512-DrCKkaQwHexjRUFTmPzs7sHQe0TSj9nvDALKGdwmK5mW9v7j90BudWirKAJHt3QQ9Dhrg1F7DogPzhChppkJpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@ungap/structured-clone": "^1.3.0", + "jest-util": "30.3.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "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-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "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/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "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/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-esm": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/load-esm/-/load-esm-1.0.3.tgz", + "integrity": "sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + }, + { + "type": "buymeacoffee", + "url": "https://buymeacoffee.com/borewit" + } + ], + "license": "MIT", + "engines": { + "node": ">=13.2.0" + } + }, + "node_modules/loader-runner": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", + "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "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/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/magic-string": { + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/memfs": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.3.tgz", + "integrity": "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "fs-monkey": "^1.0.4" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "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==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "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/multer": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "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/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-emoji": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz", + "integrity": "sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "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==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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/path-scurry/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==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "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/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", + "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/pretty-format": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", + "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "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": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "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/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/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 8" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "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/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/sql-highlight": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/sql-highlight/-/sql-highlight-6.1.0.tgz", + "integrity": "sha512-ed7OK4e9ywpE7pgRMkMQmZDPKSVdm0oX5IEtZiKnFucSF0zu6c80GZBe38UqHuVhTWJ9xsKgSMjCG2bml86KvA==", + "funding": [ + "https://github.com/scriptcoded/sql-highlight?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/scriptcoded" + } + ], + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "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==", + "dev": true, + "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/string-width-cjs": { + "name": "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strtok3": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-observable": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", + "integrity": "sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/synckit": { + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", + "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.9" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/tapable": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.46.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz", + "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.4.0.tgz", + "integrity": "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser-webpack-plugin/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "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/terser-webpack-plugin/node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/terser-webpack-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/terser-webpack-plugin/node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/terser-webpack-plugin/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/terser-webpack-plugin/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser-webpack-plugin/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "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", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "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/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "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/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "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/ts-jest": { + "version": "29.4.6", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.6.tgz", + "integrity": "sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.8", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.7.3", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <6" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ts-loader": { + "version": "9.5.4", + "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.4.tgz", + "integrity": "sha512-nCz0rEwunlTZiy6rXFByQU1kVVpCIgUpc/psFiKVrUwrizdnIbRFu8w7bxhUF0X613DYwT4XzrZHpVyMe758hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.0.0", + "micromatch": "^4.0.0", + "semver": "^7.3.4", + "source-map": "^0.7.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "typescript": "*", + "webpack": "^5.0.0" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tsconfig-paths-webpack-plugin": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths-webpack-plugin/-/tsconfig-paths-webpack-plugin-4.2.0.tgz", + "integrity": "sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.7.0", + "tapable": "^2.2.1", + "tsconfig-paths": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/tsconfig-paths/node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=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==", + "license": "0BSD" + }, + "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/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/typeorm": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-1.0.0.tgz", + "integrity": "sha512-2mSKNqucP8vo+xQLP59xlHUcqLvG6qajxA7q7tnhJgeZjTrA6lK/Ar7LRyiAxdXhyXmGbIPsArPmcUB9Xg+M7w==", + "license": "MIT", + "dependencies": { + "@sqltools/formatter": "^1.2.5", + "ansis": "^4.2.0", + "dayjs": "^1.11.20", + "debug": "^4.4.3", + "dedent": "^1.7.2", + "reflect-metadata": "^0.2.2", + "sql-highlight": "^6.1.0", + "tinyglobby": "^0.2.16", + "tslib": "^2.8.1", + "yargs": "^18.0.0" + }, + "bin": { + "typeorm": "cli.js", + "typeorm-ts-node-commonjs": "cli-ts-node-commonjs.js", + "typeorm-ts-node-esm": "cli-ts-node-esm.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.11.0" + }, + "funding": { + "url": "https://opencollective.com/typeorm" + }, + "peerDependencies": { + "@google-cloud/spanner": "^8.0.0", + "@sap/hana-client": "^2.14.22", + "better-sqlite3": "^12.0.0", + "ioredis": "^5.0.4", + "mongodb": "^7.0.0", + "mssql": "^12.0.0", + "mysql2": "^3.15.3", + "oracledb": "^6.3.0", + "pg": "^8.5.1", + "pg-native": "^3.0.0", + "pg-query-stream": "^4.0.0", + "redis": "^5.0.0", + "sql.js": "^1.4.0", + "ts-node": "^10.9.2", + "typeorm-aurora-data-api-driver": "^3.0.0" + }, + "peerDependenciesMeta": { + "@google-cloud/spanner": { + "optional": true + }, + "@sap/hana-client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "mongodb": { + "optional": true + }, + "mssql": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "oracledb": { + "optional": true + }, + "pg": { + "optional": true + }, + "pg-native": { + "optional": true + }, + "pg-query-stream": { + "optional": true + }, + "redis": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "ts-node": { + "optional": true + }, + "typeorm-aurora-data-api-driver": { + "optional": true + } + } + }, + "node_modules/typeorm/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/typeorm/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/typeorm/node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/typeorm/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/typeorm/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typeorm/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/typeorm/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/typeorm/node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/typeorm/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "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.57.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.2.tgz", + "integrity": "sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.57.2", + "@typescript-eslint/parser": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/utils": "8.57.2" + }, + "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.0.0" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/uid": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/uid/-/uid-2.0.2.tgz", + "integrity": "sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==", + "license": "MIT", + "dependencies": { + "@lukeed/csprng": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, + "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/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/watchpack": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/webpack": { + "version": "5.106.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.106.0.tgz", + "integrity": "sha512-Pkx5joZ9RrdgO5LBkyX1L2ZAJeK/Taz3vqZ9CbcP0wS5LEMx5QkKsEwLl29QJfihZ+DKRBFldzy1O30pJ1MDpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.16.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.20.0", + "es-module-lexer": "^2.0.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.3.1", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.17", + "watchpack": "^2.5.1", + "webpack-sources": "^3.3.4" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-node-externals": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/webpack-node-externals/-/webpack-node-externals-3.0.0.tgz", + "integrity": "sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/webpack-sources": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.4.tgz", + "integrity": "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "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/webpack/node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/webpack/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/webpack/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/webpack/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/webpack/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/webpack/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "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/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "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==", + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "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": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..a11e2fc --- /dev/null +++ b/backend/package.json @@ -0,0 +1,84 @@ +{ + "name": "backend", + "version": "0.0.1", + "description": "", + "author": "", + "private": true, + "license": "UNLICENSED", + "scripts": { + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "test": "jest", + "test:watch": "jest --watch", + "test:cov": "jest --coverage", + "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", + "test:e2e": "jest --config ./test/jest-e2e.json", + "migration:generate": "npx typeorm-ts-node-commonjs migration:generate -d src/data-source.ts", + "migration:run": "npx typeorm-ts-node-commonjs migration:run -d src/data-source.ts", + "migration:revert": "npx typeorm-ts-node-commonjs migration:revert -d src/data-source.ts" + }, + "dependencies": { + "@nestjs/common": "^11.0.1", + "@nestjs/config": "^4.0.4", + "@nestjs/core": "^11.1.18", + "@nestjs/jwt": "^11.0.2", + "@nestjs/platform-express": "^11.1.18", + "@nestjs/schedule": "^6.1.3", + "@nestjs/throttler": "^6.5.0", + "@nestjs/typeorm": "^11.0.0", + "helmet": "^8.1.0", + "jsonwebtoken": "^9.0.3", + "pg": "^8.20.0", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1", + "typeorm": "^1.0.0" + }, + "devDependencies": { + "@eslint/eslintrc": "^3.2.0", + "@eslint/js": "^9.18.0", + "@nestjs/cli": "^11.0.0", + "@nestjs/schematics": "^11.0.0", + "@nestjs/testing": "^11.0.1", + "@types/express": "^5.0.0", + "@types/jest": "^30.0.0", + "@types/jsonwebtoken": "^9.0.10", + "@types/node": "^22.10.7", + "@types/supertest": "^6.0.2", + "eslint": "^9.18.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-prettier": "^5.2.2", + "globals": "^16.0.0", + "jest": "^30.0.0", + "prettier": "^3.4.2", + "source-map-support": "^0.5.21", + "supertest": "^7.0.0", + "ts-jest": "^29.2.5", + "ts-loader": "^9.5.2", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.7.3", + "typescript-eslint": "^8.20.0" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], + "coverageDirectory": "../coverage", + "testEnvironment": "node" + } +} diff --git a/backend/src/admin/activity-stats.ts b/backend/src/admin/activity-stats.ts new file mode 100644 index 0000000..e6a98cf --- /dev/null +++ b/backend/src/admin/activity-stats.ts @@ -0,0 +1,324 @@ +// Activity analysis over a participant's Hackatime heartbeats — produces a +// timeline, breakdowns by editor/category, and a `filterable` index so the +// client can recompute active minutes when an activity type is excluded. + +export type Heartbeat = { + time: number; + entity: string; + type: string; + project?: string; + branch?: string; + language?: string; + editor?: string; + operating_system?: string; + user_agent?: string; + category?: string; + is_write?: boolean; + lineno?: number; + cursorpos?: number; + lines?: number; + line_additions?: number; + line_deletions?: number; +}; + +export type Severity = 'none' | 'low' | 'high'; + +export type Analysis = { + summary: { + count: number; + spanSeconds: number; + activeMinutes: number; + firstAt: number; + lastAt: number; + coveredDays: number; + aiPercent: number; + aiCount: number; + pasteLikeCount: number; + }; + editorBreakdown: Record; + categoryBreakdown: Record; + userAgents: Array<{ ua: string; count: number }>; + anomalies: { + autoClicker: { severity: Severity; evidence: string }; + macroTyper: { severity: Severity; evidence: string }; + offRepo: { count: number; sampleEntities: string[] }; + }; + // [times, lineNumbers, cursorPositions] for plotting. + points: [number[], (number | null)[], (number | null)[]]; + // per-heartbeat (time, editor index, category index) for client-side + // exclusion-driven active-minute recomputation. + filterable: { + editorList: string[]; + categoryList: string[]; + times: number[]; + editorIdx: number[]; + categoryIdx: number[]; + }; +}; + +function stddev(values: number[]): number { + if (values.length < 2) return 0; + const m = values.reduce((a, b) => a + b, 0) / values.length; + const variance = values.reduce((a, b) => a + (b - m) ** 2, 0) / values.length; + return Math.sqrt(variance); +} + +function mean(values: number[]): number { + if (values.length === 0) return 0; + return values.reduce((a, b) => a + b, 0) / values.length; +} + +function basename(p: string): string { + const idx = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\')); + return idx >= 0 ? p.slice(idx + 1) : p; +} + +function entityMatchesRepo( + entity: string, + repoBasenames: Set, + repoPaths: Set, +): boolean { + if (!entity) return false; + if (repoBasenames.has(basename(entity))) return true; + const norm = entity.replace(/\\/g, '/'); + for (const p of repoPaths) { + if (norm.endsWith(p)) return true; + } + return false; +} + +const AI_EDITOR_RE = + /\b(cursor|windsurf|cline|continue|aider|copilot|trae|zed-ai|tabnine|codeium|cody|amazonq|amazon-q|claude|claude-code|claudecode|anthropic)\b/i; +const AI_CATEGORY_RE = /\bai\b|copilot|cursor|aider|codeium|cody|claude|anthropic/i; +const LARGE_ADDITIONS_THRESHOLD = 30; + +export function analyzeActivity( + hb: Heartbeat[], + repoFilePaths: Set = new Set(), +): Analysis { + const sorted = [...hb].sort((a, b) => a.time - b.time); + const count = sorted.length; + const firstAt = count > 0 ? sorted[0].time : 0; + const lastAt = count > 0 ? sorted[count - 1].time : 0; + const spanSeconds = Math.max(0, lastAt - firstAt); + + const repoBasenames = new Set(); + for (const p of repoFilePaths) repoBasenames.add(basename(p)); + + // active minutes via 2-min idle gap + const minuteBuckets = new Map(); + for (const h of sorted) { + const m = Math.floor(h.time / 60); + const arr = minuteBuckets.get(m) ?? []; + arr.push(h); + minuteBuckets.set(m, arr); + } + let activeMinutes = 0; + let lastT = -Infinity; + for (const h of sorted) { + if (h.time - lastT > 120) { + activeMinutes += 1; + } else { + const lastMinute = Math.floor(lastT / 60); + const thisMinute = Math.floor(h.time / 60); + if (thisMinute !== lastMinute) activeMinutes += 1; + } + lastT = h.time; + } + + const coveredDayKeys = new Set(); + for (const h of sorted) { + const d = new Date(h.time * 1000); + coveredDayKeys.add(`${d.getUTCFullYear()}-${d.getUTCMonth()}-${d.getUTCDate()}`); + } + + const editorBreakdown: Record = {}; + const categoryBreakdown: Record = {}; + const uaCounts = new Map(); + for (const h of sorted) { + const ed = h.editor ?? 'unknown'; + editorBreakdown[ed] = (editorBreakdown[ed] ?? 0) + 1; + const cat = h.category ?? 'unknown'; + categoryBreakdown[cat] = (categoryBreakdown[cat] ?? 0) + 1; + const ua = h.user_agent ?? 'unknown'; + uaCounts.set(ua, (uaCounts.get(ua) ?? 0) + 1); + } + + let aiCount = 0; + let pasteLikeCount = 0; + for (const h of sorted) { + const ed = (h.editor ?? '').toLowerCase(); + const cat = (h.category ?? '').toLowerCase(); + const ua = (h.user_agent ?? '').toLowerCase(); + const additions = h.line_additions ?? 0; + const editorIsAi = AI_EDITOR_RE.test(ed) || AI_EDITOR_RE.test(ua); + const categoryIsAi = AI_CATEGORY_RE.test(cat); + const looksLikePaste = additions >= LARGE_ADDITIONS_THRESHOLD; + if (editorIsAi || categoryIsAi || looksLikePaste) { + aiCount += 1; + if (!editorIsAi && !categoryIsAi && looksLikePaste) pasteLikeCount += 1; + } + } + const aiPercent = count > 0 ? Math.round((aiCount / count) * 100) : 0; + const userAgents = [...uaCounts.entries()] + .map(([ua, c]) => ({ ua, count: c })) + .sort((a, b) => b.count - a.count); + + // autoClicker: near-flat cursorpos across consecutive minutes with writes + let flatStreak = 0; + let flatStreakWithWrites = false; + let observedFlatWithWrites = 0; + const sortedBucketKeys = [...minuteBuckets.keys()].sort((a, b) => a - b); + for (let i = 0; i < sortedBucketKeys.length; i++) { + const k = sortedBucketKeys[i]; + const bucket = minuteBuckets.get(k) ?? []; + const cps = bucket + .map((b) => b.cursorpos) + .filter((v): v is number => typeof v === 'number'); + const sd = stddev(cps); + const hasWrites = bucket.some((b) => b.is_write === true); + const consecutive = i > 0 && sortedBucketKeys[i - 1] === k - 1; + if (sd < 5 && cps.length >= 2) { + flatStreak = consecutive ? flatStreak + 1 : 1; + if (hasWrites) flatStreakWithWrites = true; + } else { + if (flatStreak >= 3 && flatStreakWithWrites) { + observedFlatWithWrites = Math.max(observedFlatWithWrites, flatStreak); + } + flatStreak = 0; + flatStreakWithWrites = false; + } + } + if (flatStreak >= 3 && flatStreakWithWrites) { + observedFlatWithWrites = Math.max(observedFlatWithWrites, flatStreak); + } + const autoClicker: Analysis['anomalies']['autoClicker'] = + observedFlatWithWrites >= 5 + ? { + severity: 'high', + evidence: `${observedFlatWithWrites} minutes of near-flat cursor with active writes`, + } + : observedFlatWithWrites >= 3 + ? { + severity: 'low', + evidence: `${observedFlatWithWrites} minutes of near-flat cursor with active writes`, + } + : { severity: 'none', evidence: '' }; + + // macroTyper: sliding window of 20 inter-heartbeat intervals, near-constant + const intervals: number[] = []; + for (let i = 1; i < sorted.length; i++) { + intervals.push(sorted[i].time - sorted[i - 1].time); + } + let macroTyper: Analysis['anomalies']['macroTyper'] = { + severity: 'none', + evidence: '', + }; + if (intervals.length >= 20) { + let flagged: { start: number; end: number; m: number; sd: number } | null = + null; + for (let i = 0; i + 20 <= intervals.length; i++) { + const win = intervals.slice(i, i + 20); + const m = mean(win); + const sd = stddev(win); + if (m < 10 && sd < 0.3) { + if (!flagged || sd < flagged.sd) { + flagged = { start: sorted[i].time, end: sorted[i + 20].time, m, sd }; + } + } + } + if (flagged) { + macroTyper = { + severity: flagged.sd < 0.1 ? 'high' : 'low', + evidence: `cluster ${new Date(flagged.start * 1000).toISOString()} → ${new Date( + flagged.end * 1000, + ).toISOString()} (mean ${flagged.m.toFixed(2)}s, stddev ${flagged.sd.toFixed(3)}s)`, + }; + } + } + + // offRepo — only meaningful when we have a repo file tree (beest doesn't). + const offEntities = new Set(); + let offCount = 0; + if (repoFilePaths.size > 0) { + for (const h of sorted) { + if (!entityMatchesRepo(h.entity, repoBasenames, repoFilePaths)) { + offCount += 1; + if (offEntities.size < 5) offEntities.add(h.entity); + } + } + } + + // points downsample + const maxPoints = 50_000; + const stride = sorted.length > maxPoints ? Math.ceil(sorted.length / maxPoints) : 1; + const xs: number[] = []; + const lines: (number | null)[] = []; + const cursors: (number | null)[] = []; + for (let i = 0; i < sorted.length; i += stride) { + const h = sorted[i]; + xs.push(h.time); + lines.push(typeof h.lineno === 'number' ? h.lineno : null); + cursors.push(typeof h.cursorpos === 'number' ? h.cursorpos : null); + } + + // filterable per-heartbeat indices for client-side exclusion recompute + const editorIndexMap = new Map(); + const categoryIndexMap = new Map(); + const editorList: string[] = []; + const categoryList: string[] = []; + const fTimes: number[] = []; + const fEd: number[] = []; + const fCat: number[] = []; + const filterStride = sorted.length > 50_000 ? Math.ceil(sorted.length / 50_000) : 1; + for (let i = 0; i < sorted.length; i += filterStride) { + const h = sorted[i]; + const ed = h.editor ?? 'unknown'; + const cat = h.category ?? 'unknown'; + let eIdx = editorIndexMap.get(ed); + if (eIdx === undefined) { + eIdx = editorList.length; + editorList.push(ed); + editorIndexMap.set(ed, eIdx); + } + let cIdx = categoryIndexMap.get(cat); + if (cIdx === undefined) { + cIdx = categoryList.length; + categoryList.push(cat); + categoryIndexMap.set(cat, cIdx); + } + fTimes.push(h.time); + fEd.push(eIdx); + fCat.push(cIdx); + } + + return { + summary: { + count, + spanSeconds, + activeMinutes, + firstAt, + lastAt, + coveredDays: coveredDayKeys.size, + aiPercent, + aiCount, + pasteLikeCount, + }, + editorBreakdown, + categoryBreakdown, + userAgents, + anomalies: { + autoClicker, + macroTyper, + offRepo: { count: offCount, sampleEntities: [...offEntities] }, + }, + points: [xs, lines, cursors], + filterable: { + editorList, + categoryList, + times: fTimes, + editorIdx: fEd, + categoryIdx: fCat, + }, + }; +} diff --git a/backend/src/admin/admin.controller.ts b/backend/src/admin/admin.controller.ts new file mode 100644 index 0000000..0074f0c --- /dev/null +++ b/backend/src/admin/admin.controller.ts @@ -0,0 +1,542 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Param, + Body, + Req, + Res, + UseGuards, + BadRequestException, + ParseUUIDPipe, +} from '@nestjs/common'; +import type { Request, Response } from 'express'; +import { SuperAdminGuard } from './super-admin.guard'; +import { ReviewerGuard } from './reviewer.guard'; +import { FraudReviewerGuard } from './fraud-reviewer.guard'; +import { AdminService } from './admin.service'; +import { AuditService, type AuditAction } from './audit.service'; +import { AuditLogService } from '../audit-log/audit-log.service'; +import { AuthService } from '../auth/auth.service'; +import { ShopService } from '../shop/shop.service'; +import { DevlogsService } from '../devlogs/devlogs.service'; + +@Controller('api/admin') +export class AdminController { + constructor( + private readonly adminService: AdminService, + private readonly auditService: AuditService, + private readonly auditLogService: AuditLogService, + private readonly authService: AuthService, + private readonly shopService: ShopService, + private readonly devlogsService: DevlogsService, + ) {} + + @UseGuards(SuperAdminGuard) + @Get('users') + listUsers() { + return this.adminService.listUsers(); + } + + @UseGuards(SuperAdminGuard) + @Get('users/:id') + getUser(@Param('id', ParseUUIDPipe) id: string) { + return this.adminService.getUser(id); + } + + @UseGuards(SuperAdminGuard) + @Post('users/:id/ban') + async banUser( + @Param('id', ParseUUIDPipe) id: string, + @Req() req: Request, + ) { + const adminId = (req as any).user?.uid; + await this.adminService.banUser(id, adminId); + return { success: true }; + } + + @UseGuards(SuperAdminGuard) + @Patch('users/:id/perms') + async updatePerms( + @Param('id', ParseUUIDPipe) id: string, + @Body() body: { perms?: string }, + @Req() req: Request, + ) { + if (!body.perms || typeof body.perms !== 'string') { + throw new BadRequestException('perms is required'); + } + const adminId = (req as any).user?.uid; + await this.adminService.updatePerms(id, body.perms, adminId); + return { success: true }; + } + + @UseGuards(SuperAdminGuard) + @Patch('users/:id/pipes') + async adjustPipes( + @Param('id', ParseUUIDPipe) id: string, + @Body() body: { delta?: number; reason?: string | null }, + @Req() req: Request, + ) { + if (typeof body.delta !== 'number' || !Number.isInteger(body.delta) || body.delta === 0) { + throw new BadRequestException('delta must be a non-zero integer'); + } + const MAX_DELTA = 100_000; + if (Math.abs(body.delta) > MAX_DELTA) { + throw new BadRequestException(`delta must be between -${MAX_DELTA} and ${MAX_DELTA}`); + } + const reason = typeof body.reason === 'string' ? body.reason.trim().slice(0, 200) : null; + const adminId = (req as any).user?.uid; + const result = await this.adminService.adjustPipes(id, body.delta, reason || null, adminId); + return { success: true, pipes: result.pipes }; + } + + @UseGuards(SuperAdminGuard) + @Post('users/:id/impersonate') + async impersonateUser( + @Param('id', ParseUUIDPipe) id: string, + @Req() req: Request, + ) { + const admin = (req as any).user; + const adminUid = admin?.uid as string; + const adminName = admin?.name as string ?? 'Admin'; + + // Look up target user's nickname for a friendlier log + const targetUser = await this.adminService.getUser(id); + const targetNick = targetUser.nickname || targetUser.name || id; + + // Log impersonation on both accounts + await this.auditLogService.log(adminUid, 'admin_impersonate', `Started impersonating ${targetNick}`); + await this.auditLogService.log(id, 'admin_impersonate', `Admin ${adminName} started impersonating this account`); + + return this.authService.issueImpersonationToken(id, adminUid, adminName); + } + + @UseGuards(SuperAdminGuard) + @Get('stats/dau') + getDailyActiveUsers() { + return this.adminService.getDailyActiveUsers(); + } + + @UseGuards(SuperAdminGuard) + @Get('stats/dau/history') + getDauHistory() { + return this.adminService.getDauHistory(); + } + + @UseGuards(SuperAdminGuard) + @Get('stats/signups') + getSignupsHistory() { + return this.adminService.getSignupsHistory(); + } + + @UseGuards(SuperAdminGuard) + @Get('stats/funnel') + getUserFunnel() { + return this.adminService.getUserFunnel(); + } + + @UseGuards(SuperAdminGuard) + @Get('stats/unreviewed-hours') + getUnreviewedHours() { + return this.adminService.getUnreviewedHours(); + } + + // ── Projects ── + + @UseGuards(ReviewerGuard) + @Get('projects') + listProjects(@Req() req: Request) { + const isSuperAdmin = (req as any).user?.perms === 'Super Admin'; + return this.adminService.listAllProjects(isSuperAdmin); + } + + @UseGuards(ReviewerGuard) + @Get('projects/:id/hackatime') + getProjectHackatime( + @Param('id', ParseUUIDPipe) id: string, + @Req() req: Request, + ) { + const isSuperAdmin = (req as any).user?.perms === 'Super Admin'; + return this.adminService.getProjectHackatime(id, isSuperAdmin); + } + + @UseGuards(ReviewerGuard) + @Post('projects/:id/review') + async reviewProject( + @Param('id', ParseUUIDPipe) id: string, + @Body() body: { + status?: string; + feedback?: string; + internalNote?: string; + overrideJustification?: string; + overrideHours?: number; + internalHours?: number; + }, + @Req() req: Request, + ) { + const validStatuses = ['approved', 'changes_needed', 'ban']; + if (!body.status || !validStatuses.includes(body.status)) { + throw new BadRequestException(`status must be one of: ${validStatuses.join(', ')}`); + } + + const reviewer = (req as any).user; + const reviewerId = reviewer?.uid; + const isSuperAdmin = reviewer?.perms === 'Super Admin'; + const canBan = isSuperAdmin || reviewer?.perms === 'Fraud Reviewer'; + + if (body.status === 'ban' && !canBan) { + throw new BadRequestException('Only Super Admins and Fraud Reviewers can ban users. Flag this project in your internal note and ping Euan.'); + } + + const HOURS_CAP = 500; + for (const [field, value] of [ + ['overrideHours', body.overrideHours] as const, + ['internalHours', body.internalHours] as const, + ]) { + if (value === undefined || value === null) continue; + if (!Number.isFinite(value) || value < 0 || value > HOURS_CAP) { + throw new BadRequestException(`${field} must be a finite number between 0 and ${HOURS_CAP}`); + } + } + + // Reviewer must add their own reasoning beyond the auto-generated template + // (~180 chars) — require at least 250 chars on overrideJustification for an + // approve action so approvals aren't rubber-stamped. Rejections don't need + // a long justification (the feedback field carries the user-facing reason). + if (body.status === 'approved') { + const justification = (body.overrideJustification ?? '').trim(); + const JUSTIFICATION_MIN = 250; + if (justification.length < JUSTIFICATION_MIN) { + throw new BadRequestException( + `Override Justification must be at least ${JUSTIFICATION_MIN} characters — please add at least 70 characters of your own reasoning beyond the auto-generated template.`, + ); + } + } + + if (body.status === 'ban') { + return this.adminService.banAndRejectProject( + id, + reviewerId, + body.feedback ?? null, + body.internalNote ?? null, + body.overrideJustification ?? null, + ); + } + + return this.adminService.reviewProject( + id, + reviewerId, + body.status, + body.feedback ?? null, + body.internalNote ?? null, + body.overrideJustification ?? null, + body.overrideHours ?? null, + body.internalHours ?? null, + ); + } + + @UseGuards(ReviewerGuard) + @Get('projects/:id/reviews') + getProjectReviews(@Param('id', ParseUUIDPipe) id: string) { + return this.adminService.getProjectReviews(id, true); + } + + /** Devlog entries authored by the project owner and linked to this project. */ + @UseGuards(ReviewerGuard) + @Get('projects/:id/devlogs') + getProjectDevlogs(@Param('id', ParseUUIDPipe) id: string) { + return this.devlogsService.findByProject(id); + } + + @UseGuards(ReviewerGuard) + @Get('review-leaderboard') + getReviewLeaderboard(@Req() req: Request) { + const query = (req as any).query ?? {}; + const win = (query.window as string) ?? '7d'; + const validWindows = ['24h', '7d', '30d', 'all']; + if (!validWindows.includes(win)) { + throw new BadRequestException(`window must be one of: ${validWindows.join(', ')}`); + } + return this.adminService.getReviewLeaderboard(win as '24h' | '7d' | '30d' | 'all'); + } + + // ── Admin audit queue ── + // Projects parked in 'fraud_pending' after first-reviewer approval are queued + // here for a second-pass audit before pipes are paid out and the project syncs + // to Airtable. Open to Super Admin and Fraud Reviewer. + + @UseGuards(FraudReviewerGuard) + @Get('audit/queue') + auditQueue() { + return this.auditService.listQueue(); + } + + // Super-admin-only escape hatch: when the audit queue is empty, pull up to + // 10 oldest unreviewed projects in as one-shot reviews (skips first-pass). + @UseGuards(SuperAdminGuard) + @Post('audit/load-unreviewed') + async auditLoadUnreviewed(@Req() req: Request) { + const superAdminId = (req as any).user?.uid; + return this.auditService.loadUnreviewedIntoQueue(superAdminId); + } + + @UseGuards(FraudReviewerGuard) + @Post('audit/:id/decision') + async auditDecision( + @Param('id', ParseUUIDPipe) id: string, + @Body() + body: { + action?: string; + overrideHours?: number | null; + internalHours?: number | null; + justification?: string | null; + reviewerFeedback?: string | null; + userFeedback?: string | null; + }, + @Req() req: Request, + ) { + const validActions = ['approve', 'rereview', 'reject', 'ban']; + if (!body.action || !validActions.includes(body.action)) { + throw new BadRequestException( + `action must be one of: ${validActions.join(', ')}`, + ); + } + const reviewer = (req as any).user; + const auditorId = reviewer?.uid; + const isSuperAdmin = reviewer?.perms === 'Super Admin'; + if (body.action === 'ban' && !isSuperAdmin) { + throw new BadRequestException( + 'Only Super Admins can ban from the audit panel.', + ); + } + return this.auditService.decide(id, auditorId, { + action: body.action as AuditAction, + overrideHours: body.overrideHours ?? null, + internalHours: body.internalHours ?? null, + justification: body.justification ?? null, + reviewerFeedback: body.reviewerFeedback ?? null, + userFeedback: body.userFeedback ?? null, + isSuperAdmin, + }); + } + + @UseGuards(FraudReviewerGuard) + @Get('audit/:id/activity') + async auditActivity( + @Param('id', ParseUUIDPipe) id: string, + @Res() res: Response, + ) { + res.setHeader('Content-Type', 'application/x-ndjson; charset=utf-8'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('X-Accel-Buffering', 'no'); + try { + for await (const evt of this.auditService.streamActivityEvents(id)) { + res.write(JSON.stringify(evt) + '\n'); + // flush eagerly so the client sees per-day progress + (res as any).flush?.(); + } + } catch { + res.write(JSON.stringify({ type: 'error', error: 'stream-failed' }) + '\n'); + } finally { + res.end(); + } + } + + @UseGuards(SuperAdminGuard) + @Post('projects/:id/resync-airtable') + async resyncAirtable( + @Param('id', ParseUUIDPipe) id: string, + @Req() req: Request, + ) { + const reviewerId = (req as any).user?.uid; + return this.adminService.resyncProjectToAirtable(id, reviewerId); + } + + // ── News CRUD ── + + @UseGuards(SuperAdminGuard) + @Get('news') + listNews() { + return this.adminService.listNews(); + } + + @UseGuards(SuperAdminGuard) + @Post('news') + async createNews(@Body() body: { text?: string; displayDate?: string }) { + if (!body.text || !body.displayDate) { + throw new BadRequestException('text and displayDate are required'); + } + return this.adminService.createNews(body.text, body.displayDate); + } + + @UseGuards(SuperAdminGuard) + @Patch('news/:id') + async updateNews( + @Param('id', ParseUUIDPipe) id: string, + @Body() body: { text?: string; displayDate?: string }, + ) { + return this.adminService.updateNews(id, body); + } + + @UseGuards(SuperAdminGuard) + @Delete('news/:id') + async deleteNews(@Param('id', ParseUUIDPipe) id: string) { + await this.adminService.deleteNews(id); + return { success: true }; + } + + // ── Shop CRUD ── + + @UseGuards(SuperAdminGuard) + @Get('shop') + listShopItems() { + return this.adminService.listShopItems(); + } + + @UseGuards(SuperAdminGuard) + @Post('shop') + async createShopItem(@Body() body: { + name?: string; + description?: string; + detailedDescription?: string | null; + imageUrl?: string; + priceHours?: number; + stock?: number | null; + estimatedShip?: string | null; + isActive?: boolean; + isFeatured?: boolean; + }) { + if (!body.name || !body.description || !body.imageUrl || body.priceHours == null) { + throw new BadRequestException('name, description, imageUrl, and priceHours are required'); + } + if (!Number.isInteger(body.priceHours) || body.priceHours < 1) { + throw new BadRequestException('priceHours must be a positive integer'); + } + if (body.stock !== undefined && body.stock !== null) { + if (!Number.isInteger(body.stock) || body.stock < 0) { + throw new BadRequestException('stock must be a non-negative integer or null'); + } + } + return this.adminService.createShopItem({ + name: body.name, + description: body.description, + detailedDescription: body.detailedDescription, + imageUrl: body.imageUrl, + priceHours: body.priceHours, + stock: body.stock, + estimatedShip: body.estimatedShip, + isActive: body.isActive, + isFeatured: body.isFeatured, + }); + } + + @UseGuards(SuperAdminGuard) + @Patch('shop/reorder') + async reorderShopItems(@Body() body: { items?: { id: string; sortOrder: number }[] }) { + if (!Array.isArray(body.items) || body.items.length === 0) { + throw new BadRequestException('items array is required'); + } + for (const item of body.items) { + if (!item.id || typeof item.sortOrder !== 'number' || !Number.isInteger(item.sortOrder) || item.sortOrder < 0) { + throw new BadRequestException('each item must have a valid id and non-negative integer sortOrder'); + } + } + await this.adminService.reorderShopItems(body.items); + return { success: true }; + } + + @UseGuards(SuperAdminGuard) + @Patch('shop/:id') + async updateShopItem( + @Param('id', ParseUUIDPipe) id: string, + @Body() body: { + name?: string; + description?: string; + detailedDescription?: string | null; + imageUrl?: string; + priceHours?: number; + stock?: number | null; + estimatedShip?: string | null; + isActive?: boolean; + isFeatured?: boolean; + }, + ) { + if (body.priceHours !== undefined) { + if (!Number.isInteger(body.priceHours) || body.priceHours < 1) { + throw new BadRequestException('priceHours must be a positive integer'); + } + } + if (body.stock !== undefined && body.stock !== null) { + if (!Number.isInteger(body.stock) || body.stock < 0) { + throw new BadRequestException('stock must be a non-negative integer or null'); + } + } + return this.adminService.updateShopItem(id, body); + } + + @UseGuards(SuperAdminGuard) + @Delete('shop/:id') + async deleteShopItem(@Param('id', ParseUUIDPipe) id: string) { + await this.adminService.deleteShopItem(id); + return { success: true }; + } + + // ── Orders / Fulfillment ── + + @UseGuards(SuperAdminGuard) + @Get('orders') + listOrders(@Req() req: Request) { + const query = (req as any).query ?? {}; + return this.shopService.listAllOrders({ + shopItemId: query.shopItemId, + status: query.status, + sortBy: query.sortBy, + }); + } + + @UseGuards(SuperAdminGuard) + @Get('orders/:id/detail') + async getOrderDetail(@Param('id', ParseUUIDPipe) id: string) { + return this.adminService.getOrderDetailForFulfillment(id); + } + + @UseGuards(SuperAdminGuard) + @Post('orders/:id/fulfill') + async fulfillOrder(@Param('id', ParseUUIDPipe) id: string) { + return this.shopService.fulfillOrder(id); + } + + @UseGuards(SuperAdminGuard) + @Post('orders/:id/refund') + async refundOrder( + @Param('id', ParseUUIDPipe) id: string, + @Req() req: Request, + ) { + const adminId = (req as any).user?.uid; + return this.shopService.refundOrder(id, { adminId }); + } + + @UseGuards(SuperAdminGuard) + @Post('orders/:id/merge') + async mergeOrder( + @Param('id', ParseUUIDPipe) id: string, + @Req() req: Request, + ) { + const adminId = (req as any).user?.uid; + return this.shopService.mergeOrders(id, adminId); + } + + @UseGuards(SuperAdminGuard) + @Post('orders/:id/message') + async sendFulfillmentMessage( + @Param('id', ParseUUIDPipe) id: string, + @Body() body: { message?: string }, + ) { + if (!body.message || typeof body.message !== 'string') { + throw new BadRequestException('message is required'); + } + return this.shopService.sendFulfillmentMessage(id, body.message); + } +} diff --git a/backend/src/admin/admin.module.ts b/backend/src/admin/admin.module.ts new file mode 100644 index 0000000..87fc6f2 --- /dev/null +++ b/backend/src/admin/admin.module.ts @@ -0,0 +1,42 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthModule } from '../auth/auth.module'; +import { RsvpModule } from '../rsvp/rsvp.module'; +import { AuditLogModule } from '../audit-log/audit-log.module'; +import { User } from '../entities/user.entity'; +import { Session } from '../entities/session.entity'; +import { Project } from '../entities/project.entity'; +import { AuditLog } from '../entities/audit-log.entity'; +import { NewsItem } from '../entities/news-item.entity'; +import { ProjectReview } from '../entities/project-review.entity'; +import { ShopItem } from '../entities/shop-item.entity'; +import { Order } from '../entities/order.entity'; +import { Submission } from '../entities/submission.entity'; +import { ShopModule } from '../shop/shop.module'; +import { HcaModule } from '../hca/hca.module'; +import { DevlogsModule } from '../devlogs/devlogs.module'; +import { FraudReviewModule } from '../fraud-review/fraud-review.module'; +import { ProjectAirtableSyncModule } from '../projects/project-airtable-sync.module'; +import { AdminController } from './admin.controller'; +import { AdminService } from './admin.service'; +import { AuditService } from './audit.service'; +import { SuperAdminGuard } from './super-admin.guard'; +import { ReviewerGuard } from './reviewer.guard'; +import { FraudReviewerGuard } from './fraud-reviewer.guard'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([User, Session, Project, AuditLog, NewsItem, ProjectReview, ShopItem, Order, Submission]), + AuthModule, + RsvpModule, + AuditLogModule, + ShopModule, + HcaModule, + DevlogsModule, + FraudReviewModule, + ProjectAirtableSyncModule, + ], + controllers: [AdminController], + providers: [AdminService, AuditService, SuperAdminGuard, ReviewerGuard, FraudReviewerGuard], +}) +export class AdminModule {} diff --git a/backend/src/admin/admin.service.ts b/backend/src/admin/admin.service.ts new file mode 100644 index 0000000..1d84797 --- /dev/null +++ b/backend/src/admin/admin.service.ts @@ -0,0 +1,1667 @@ +import { + Injectable, + BadRequestException, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { User } from '../entities/user.entity'; +import { Session } from '../entities/session.entity'; +import { Project } from '../entities/project.entity'; +import { AuditLog } from '../entities/audit-log.entity'; +import { NewsItem } from '../entities/news-item.entity'; +import { ProjectReview } from '../entities/project-review.entity'; +import { ShopItem } from '../entities/shop-item.entity'; +import { Order } from '../entities/order.entity'; +import { Submission } from '../entities/submission.entity'; +import { RsvpService } from '../rsvp/rsvp.service'; +import { AuditLogService } from '../audit-log/audit-log.service'; +import { HcaService } from '../hca/hca.service'; +import { fetchWithTimeout } from '../fetch.util'; +import { ProjectAirtableSyncService } from '../projects/project-airtable-sync.service'; + +const VALID_PERMS = [ + 'User', + 'Helper', + 'Reviewer', + 'Fraud Reviewer', + 'Super Admin', + 'Banned', +] as const; + +@Injectable() +export class AdminService { + private readonly logger = new Logger(AdminService.name); + private readonly hackatimeBaseUrl: string; + private readonly hackatimeAdminKey: string | undefined; + + // DAU cache (5-minute TTL) + private dauCache: { count: number; timestamp: number } | null = null; + private readonly DAU_CACHE_TTL = 5 * 60 * 1000; + + // Unreviewed-hours cache (60-second TTL) — each refresh fans out to one + // Hackatime /spans request per (unreviewed project × linked HT name), so + // back-to-back stats-page loads must not multiply that fan-out. + private unreviewedHoursCache: { + payload: { + totalHours: number; + projectCount: number; + approvalRate: number; + decisionCount: number; + predictedApprovedHours: number; + }; + timestamp: number; + } | null = null; + private readonly UNREVIEWED_HOURS_CACHE_TTL = 60 * 1000; + + // Signups history cache (10-minute TTL) — Airtable call is moderately expensive + private signupsCache: { + payload: { daily: { date: string; count: number }[]; cumulative: { date: string; count: number }[]; total: number }; + timestamp: number; + } | null = null; + private readonly SIGNUPS_CACHE_TTL = 10 * 60 * 1000; + + // DAU history: cache of finalised per-day counts (YYYY-MM-DD → count). + // Entries are only written for dates that are fully in the past (UTC), + // so they never need invalidation. + private readonly dauHistoryCache = new Map(); + private dauHistoryInflight: Promise | null = null; + private static readonly DAU_HISTORY_START = '2026-04-03'; + // Beest event start. Hackatime hours logged before this date should not + // count toward project review totals (admin /user/projects returns lifetime + // total_duration with no date filter, so we reconstruct from spans). + private static readonly HACKATIME_EVENT_START = '2026-04-02'; + + constructor( + private readonly configService: ConfigService, + @InjectRepository(User) private readonly userRepo: Repository, + @InjectRepository(Session) private readonly sessionRepo: Repository, + @InjectRepository(Project) private readonly projectRepo: Repository, + @InjectRepository(AuditLog) private readonly auditLogRepo: Repository, + @InjectRepository(NewsItem) private readonly newsRepo: Repository, + @InjectRepository(ProjectReview) private readonly reviewRepo: Repository, + @InjectRepository(ShopItem) private readonly shopRepo: Repository, + @InjectRepository(Order) private readonly orderRepo: Repository, + @InjectRepository(Submission) private readonly submissionRepo: Repository, + private readonly rsvpService: RsvpService, + private readonly auditLogService: AuditLogService, + private readonly hcaService: HcaService, + private readonly airtableSync: ProjectAirtableSyncService, + ) { + this.hackatimeBaseUrl = this.configService.get( + 'HACKATIME_BASE_URL', + 'https://hackatime.hackclub.com', + ); + this.hackatimeAdminKey = this.configService.get('HACKATIME_ADMIN_API_KEY'); + if (!this.hackatimeAdminKey) { + this.logger.warn('HACKATIME_ADMIN_API_KEY not set — admin Hackatime lookups disabled'); + } + } + + async listUsers(): Promise { + const [users, permsMap] = await Promise.all([ + this.userRepo.find({ order: { createdAt: 'DESC' } }), + this.rsvpService.getAllPerms(), + ]); + return users.map((u) => ({ + id: u.id, + hcaSub: u.hcaSub, + name: u.name, + nickname: u.nickname, + slackId: u.slackId, + email: u.email, + hackatimeConnected: !!u.hackatimeToken, + perms: (u.email ? permsMap.get(u.email.toLowerCase()) : null) ?? null, + createdAt: u.createdAt, + })); + } + + async getUser(userId: string) { + const user = await this.userRepo.findOne({ + where: { id: userId }, + }); + if (!user) throw new NotFoundException('User not found'); + + const projects = await this.projectRepo.find({ + where: { userId }, + order: { createdAt: 'DESC' }, + select: ['id', 'name', 'status', 'projectType', 'createdAt'], + }); + + const orders = await this.orderRepo.find({ + where: { userId }, + order: { createdAt: 'DESC' }, + select: ['id', 'itemName', 'quantity', 'pipesSpent', 'status', 'createdAt'], + }); + + const sessions = await this.sessionRepo.count({ where: { userId } }); + + const auditLogs = await this.auditLogRepo.find({ + where: { userId }, + order: { createdAt: 'DESC' }, + take: 50, + select: ['id', 'action', 'label', 'createdAt'], + }); + + let perms: string | null = null; + try { + if (user.email) { + perms = await this.rsvpService.getPerms(user.email); + } + } catch { + // Airtable lookup failed — don't block the response + } + + return { + id: user.id, + hcaSub: user.hcaSub, + name: user.name, + nickname: user.nickname, + slackId: user.slackId, + email: user.email, + hackatimeConnected: !!user.hackatimeToken, + twoEmails: user.twoEmails, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + pipes: user.pipes ?? 0, + perms, + projects, + orders, + activeSessions: sessions, + auditLogs, + }; + } + + async banUser(userId: string, adminId?: string): Promise { + const user = await this.userRepo.findOne({ where: { id: userId } }); + if (!user) throw new NotFoundException('User not found'); + + // 1. Update Airtable perms to Banned + await this.rsvpService.updatePerms(user.email, 'Banned'); + + // 2. Revoke all sessions for this user + await this.sessionRepo.delete({ userId }); + + // 3. Audit log on the banned user's record + const identifier = user.name || user.slackId || user.hcaSub; + await this.auditLogService.log(userId, 'admin_ban', `Banned user ${identifier}`); + + // 4. Audit log on the admin's record + if (adminId) { + await this.auditLogService.log(adminId, 'admin_ban', `Banned user ${identifier}`); + } + } + + async banAndRejectProject( + projectId: string, + reviewerId: string, + feedback: string | null, + internalNote: string | null, + overrideJustification: string | null, + ) { + const project = await this.projectRepo.findOne({ + where: { id: projectId }, + relations: ['user'], + }); + if (!project) throw new NotFoundException('Project not found'); + if (project.userId === reviewerId) { + throw new BadRequestException('You cannot review your own project'); + } + + // 1. Reject the project + project.status = 'changes_needed'; + await this.projectRepo.save(project); + + // 2. Save review record + const review = this.reviewRepo.create({ + projectId, + reviewerId, + status: 'ban', + feedback: feedback || null, + internalNote: internalNote || null, + overrideJustification: overrideJustification || null, + }); + await this.reviewRepo.save(review); + + // 3. Ban the user + await this.banUser(project.userId); + + // 4. Audit logs + await this.auditLogService.log(project.userId, 'project_reviewed', `Project "${project.name}" was rejected`); + await this.auditLogService.log(project.userId, 'admin_ban', `Banned via project review of "${project.name}"`); + + return { success: true }; + } + + async adjustPipes( + userId: string, + delta: number, + reason: string | null, + adminId?: string, + ): Promise<{ pipes: number }> { + if (!Number.isInteger(delta) || delta === 0) { + throw new BadRequestException('delta must be a non-zero integer'); + } + + const user = await this.userRepo.findOne({ where: { id: userId } }); + if (!user) throw new NotFoundException('User not found'); + + const current = user.pipes ?? 0; + const next = current + delta; + if (next < 0) { + throw new BadRequestException( + `Cannot revoke ${-delta} pipes — user only has ${current}`, + ); + } + + if (delta > 0) { + await this.userRepo.increment({ id: userId }, 'pipes', delta); + } else { + await this.userRepo.decrement({ id: userId }, 'pipes', -delta); + } + + const identifier = user.name || user.slackId || user.hcaSub; + const verb = delta > 0 ? 'Granted' : 'Revoked'; + const reasonSuffix = reason ? ` — ${reason}` : ''; + const label = `${verb} ${Math.abs(delta)} pipes (${identifier}, ${current} → ${next})${reasonSuffix}`; + await this.auditLogService.log(userId, 'admin_pipes_adjust', label); + if (adminId) { + await this.auditLogService.log(adminId, 'admin_pipes_adjust', label); + } + + return { pipes: next }; + } + + async updatePerms(userId: string, perms: string, adminId?: string): Promise { + if (!VALID_PERMS.includes(perms as any)) { + throw new BadRequestException( + `Invalid perms value. Must be one of: ${VALID_PERMS.join(', ')}`, + ); + } + + const user = await this.userRepo.findOne({ where: { id: userId } }); + if (!user) throw new NotFoundException('User not found'); + + await this.rsvpService.updatePerms(user.email, perms); + + const identifier = user.name || user.slackId || user.hcaSub; + await this.auditLogService.log(userId, 'admin_perms_change', `Changed ${identifier} perms to ${perms}`); + + if (adminId) { + await this.auditLogService.log(adminId, 'admin_perms_change', `Changed ${identifier} perms to ${perms}`); + } + } + + // ── Projects ── + + async listAllProjects(isSuperAdmin: boolean) { + const projects = await this.projectRepo.find({ + order: { createdAt: 'DESC' }, + relations: ['user'], + }); + + const statusCounts = { + unshipped: 0, + unreviewed: 0, + fraud_pending: 0, + changes_needed: 0, + approved: 0, + }; + + // Fetch latest submission for each project in a single query + const latestSubmissions = await this.submissionRepo + .createQueryBuilder('s') + .distinctOn(['s.project_id']) + .orderBy('s.project_id') + .addOrderBy('s.created_at', 'DESC') + .getMany(); + const submissionMap = new Map(latestSubmissions.map((s) => [s.projectId, s])); + + const mapped = projects + .filter((p) => isSuperAdmin || p.status !== 'unshipped') + .map((p) => { + if (p.status in statusCounts) { + statusCounts[p.status as keyof typeof statusCounts]++; + } + const latestSub = submissionMap.get(p.id); + return { + id: p.id, + name: p.name, + description: p.description, + projectType: p.projectType, + status: p.status, + codeUrl: p.codeUrl, + demoUrl: p.demoUrl, + readmeUrl: p.readmeUrl, + screenshot1Url: p.screenshot1Url, + screenshot2Url: p.screenshot2Url, + hackatimeProjectName: p.hackatimeProjectName, + isUpdate: p.isUpdate, + otherHcProgram: p.otherHcProgram, + aiUse: p.aiUse, + createdAt: p.createdAt, + updatedAt: p.updatedAt, + user: { + id: p.user?.id, + name: isSuperAdmin ? p.user?.name : null, + slackId: p.user?.slackId, + }, + latestSubmission: latestSub ? { + id: latestSub.id, + changeDescription: latestSub.changeDescription, + minHoursConfirmed: latestSub.minHoursConfirmed, + reviewerNote: latestSub.reviewerNote, + status: latestSub.status, + createdAt: latestSub.createdAt, + } : null, + }; + }); + + return { statusCounts, projects: mapped }; + } + + async reviewProject( + projectId: string, + reviewerId: string, + status: string, + feedback: string | null, + internalNote: string | null, + overrideJustification: string | null, + overrideHours: number | null, + internalHours: number | null, + ) { + const project = await this.projectRepo.findOne({ + where: { id: projectId }, + relations: ['user'], + }); + if (!project) throw new NotFoundException('Project not found'); + if (project.userId === reviewerId) { + throw new BadRequestException('You cannot review your own project'); + } + + const previousStatus = project.status; + const previousOverrideHours = project.overrideHours; + + // Block re-reviewing an already-approved project. Reviewers must send to + // "changes needed" first (which claws back pipes and zeroes hours) before + // re-approving. This prevents: + // - duplicate Airtable rows on accidental double-approve clicks + // - inflate-on-re-approve where a reviewer raises overrideHours on a + // project that's already paid out, granting extra pipes + if (previousStatus === 'approved' && status === 'approved') { + throw new BadRequestException( + 'Project is already approved. Send to "changes needed" first if you need to re-review.', + ); + } + // Block re-approving while still waiting on the fraud-review verdict. + if (previousStatus === 'fraud_pending' && status === 'approved') { + throw new BadRequestException( + 'Project is already awaiting fraud review. Wait for the verdict before re-reviewing.', + ); + } + + // Find the latest unreviewed submission for this project + const submission = await this.submissionRepo.findOne({ + where: { projectId, status: 'unreviewed' }, + order: { createdAt: 'DESC' }, + }); + + // 1. Update project status and hours. + // + // On approval, the reviewer's submitted overrideHours/internalHours are the + // DELTA for THIS submission — new work since the last approval — and are + // ADDED on top of the project's existing approved hours, never overwriting. + // For initial ships project.overrideHours is 0, so the delta becomes the + // cumulative; for reships the delta accumulates on top of prior approvals. + // After approved → changes_needed (which wipes hours/claws back pipes), the + // project is back at 0, so a follow-up approval starts fresh from the delta. + // + // When the reviewer approves, the project does NOT go straight to 'approved'. + // It moves to 'fraud_pending' first — the joe.fraud first-pass review must + // clear before pipes are granted and the project syncs to Airtable. The + // background poller in FraudReviewService observes the verdict and either + // finalises the approval or marks the project changes_needed with a + // generic user-facing message. + project.status = status === 'approved' ? 'fraud_pending' : status; + if (status === 'approved') { + if (overrideHours !== null && overrideHours !== undefined) { + const delta = Math.round(overrideHours * 10) / 10; + project.overrideHours = Math.round(((project.overrideHours ?? 0) + delta) * 10) / 10; + } + if (internalHours !== null && internalHours !== undefined) { + const internalDelta = Math.round(internalHours * 10) / 10; + project.internalHours = Math.round(((project.internalHours ?? 0) + internalDelta) * 10) / 10; + } + } + + // Hackatime cap: reviewer cannot approve more new hours than the user has + // actually logged in Hackatime since the last approval (with a 0.5h buffer + // for rounding). This refetches Hackatime server-side at approval time so a + // tampered request body can't bypass it. + if ( + status === 'approved' && + overrideHours !== null && + overrideHours !== undefined && + overrideHours > 0 + ) { + try { + const ht = await this.getProjectHackatime(projectId, false); + const currentHackatime = ht?.totalHours ?? 0; + const previousProjectHours = previousOverrideHours ?? 0; + const allowedDelta = currentHackatime - previousProjectHours + 0.5; + const submittedDelta = Math.round(overrideHours * 10) / 10; + if (submittedDelta > allowedDelta) { + const hackatimeDelta = Math.round((currentHackatime - previousProjectHours) * 10) / 10; + throw new BadRequestException( + `Cannot approve ${submittedDelta}h of new work — Hackatime shows only ${hackatimeDelta}h of new time since last approval. Reduce the approved hours, or send to "changes needed" if the hours look wrong.`, + ); + } + } catch (e) { + if (e instanceof BadRequestException) throw e; + // Hackatime fetch failed for an unrelated reason — log and proceed, + // rather than blocking reviews on Hackatime outages. + this.logger.warn(`Hackatime cap check failed for project ${projectId}: ${e}`); + } + } + + // project.overrideHours is the CUMULATIVE total approved hours for this + // project (sum of submission deltas across all approved ships). Validate: + // - status=approved + finalHours <= 0 silently zeroes pipes_granted and, + // because the bar suppresses overflow on approved projects, makes the + // user's hours appear to vanish (moaz, 2026-05-05). + // - status=approved + finalHours < pipes_granted desyncs the bar from the + // pipes already paid out (sadrita, 2026-04-29). + // To genuinely reduce a project's hours, route through changes_needed first + // (which claws back pipes), then re-approve. + if (status === 'approved') { + const finalHours = project.overrideHours ?? 0; + if (finalHours <= 0) { + throw new BadRequestException( + 'Cannot approve a project at 0 hours. Enter a positive delta of new hours, or use "changes needed" to reject without granting pipes.', + ); + } + if (finalHours < (project.pipesGranted ?? 0)) { + throw new BadRequestException( + `Cannot reduce approved hours to ${finalHours} — ${project.pipesGranted} pipes have already been granted on this project. Send to "changes needed" first to claw back pipes.`, + ); + } + } + + await this.projectRepo.save(project); + + // 2a. Handle rejection. + // - Direct approved → changes_needed: wipe overrideHours and claw back pipes (the approval is being revoked). + // - unreviewed → changes_needed with prior approval (pipesGranted > 0): preserve the prior approval's + // hours and pipes — only the new resubmission is being rejected, the original approval still stands. + // - Any other path into changes_needed (never approved): clear overrideHours so a reviewer-set value + // on the rejection doesn't bleed into the approved bucket on the next resubmission. + if (status === 'changes_needed') { + if (previousStatus === 'approved') { + project.overrideHours = 0; + if ((project.pipesGranted ?? 0) > 0) { + const clawback = project.pipesGranted!; + await this.userRepo.decrement({ id: project.userId }, 'pipes', clawback); + project.pipesGranted = 0; + this.logger.warn(`Clawed back ${clawback} pipes from user ${project.userId} for project ${project.id}`); + } + await this.projectRepo.save(project); + } else if (previousStatus === 'unreviewed' && (project.pipesGranted ?? 0) > 0) { + project.overrideHours = previousOverrideHours; + await this.projectRepo.save(project); + } else { + project.overrideHours = 0; + await this.projectRepo.save(project); + } + } + + // 2b. Pipe granting on the approved path is DEFERRED to the fraud-review + // poller (FraudReviewService.completeApproval). Clawback on the + // changes_needed path was already handled in section 2a above. + + // 3. Update the submission status and hours. + // On the approved path the submission stays at 'unreviewed' until the + // fraud poller flips it to 'approved' (or to 'changes_needed' on + // fraud-rejection). Only update here for the non-approved paths. + if (submission) { + if (overrideHours !== null && overrideHours !== undefined) { + submission.overrideHours = Math.round(overrideHours * 10) / 10; + } + if (internalHours !== null && internalHours !== undefined) { + submission.internalHours = Math.round(internalHours * 10) / 10; + } + if (status !== 'approved') { + submission.status = status; + } + await this.submissionRepo.save(submission); + } + + // 4. Save the review record (linked to submission if one exists) + const review = this.reviewRepo.create({ + projectId, + reviewerId, + submissionId: submission?.id ?? null, + status, + feedback: feedback || null, + internalNote: internalNote || null, + overrideJustification: overrideJustification || null, + }); + await this.reviewRepo.save(review); + + // 5. Audit log to the project owner (not the reviewer) + const label = + status === 'approved' + ? `Project "${project.name}" was approved by reviewer` + : `Project "${project.name}" received feedback`; + await this.auditLogService.log(project.userId, 'project_reviewed', label); + + return { success: true }; + } + + async resyncProjectToAirtable(projectId: string, reviewerId: string) { + const project = await this.projectRepo.findOne({ + where: { id: projectId }, + relations: ['user'], + }); + if (!project) throw new NotFoundException('Project not found'); + if (project.status !== 'approved') { + throw new BadRequestException('Only approved projects can be re-pushed to Airtable'); + } + + // Find the latest review and latest approved submission for this project to + // include override justification and per-ship internal hours + const latestReview = await this.reviewRepo.findOne({ + where: { projectId }, + order: { createdAt: 'DESC' }, + }); + const latestApprovedSub = await this.submissionRepo.findOne({ + where: { projectId, status: 'approved' }, + order: { createdAt: 'DESC' }, + }); + + // Re-sync the funnel date fields + if (project.user?.email) { + this.rsvpService.updateDateField(project.user.email, 'Loops - beestApprovedProject'); + } + + // Re-push the full project record to Airtable Projects table + try { + await this.airtableSync.syncApprovedProject( + project, + latestReview?.overrideJustification ?? null, + latestApprovedSub ?? null, + ); + } catch (err) { + this.logger.error(`Airtable resync failed for project ${projectId}: ${err}`); + throw new BadRequestException('Failed to push project to Airtable — check server logs'); + } + + await this.auditLogService.log( + reviewerId, + 'admin_resync_airtable', + `Re-pushed project "${project.name}" to Airtable`, + ); + + return { success: true }; + } + + async getReviewLeaderboard(window: '24h' | '7d' | '30d' | 'all') { + const windowMs: Record = { + '24h': 24 * 60 * 60 * 1000, + '7d': 7 * 24 * 60 * 60 * 1000, + '30d': 30 * 24 * 60 * 60 * 1000, + 'all': null, + }; + const ms = windowMs[window]; + + const qb = this.reviewRepo + .createQueryBuilder('r') + .leftJoin('r.reviewer', 'u') + .select('r.reviewer_id', 'reviewerId') + .addSelect('u.name', 'reviewerName') + .addSelect('u.slack_id', 'reviewerSlackId') + .addSelect('COUNT(*)::int', 'total') + .addSelect("COUNT(*) FILTER (WHERE r.status = 'approved')::int", 'approved') + .addSelect("COUNT(*) FILTER (WHERE r.status = 'changes_needed')::int", 'changesNeeded') + .addSelect("COUNT(*) FILTER (WHERE r.status = 'ban')::int", 'banned') + .groupBy('r.reviewer_id') + .addGroupBy('u.name') + .addGroupBy('u.slack_id') + .orderBy('total', 'DESC'); + + if (ms !== null) { + qb.where('r.created_at > :cutoff', { cutoff: new Date(Date.now() - ms) }); + } + + const rows = await qb.getRawMany(); + return rows.map((r) => { + const total = Number(r.total); + const approved = Number(r.approved); + return { + reviewerId: r.reviewerId, + reviewerName: r.reviewerName, + reviewerSlackId: r.reviewerSlackId, + total, + approved, + changesNeeded: Number(r.changesNeeded), + banned: Number(r.banned), + approvalPercent: total > 0 ? Math.round((approved / total) * 100) : 0, + }; + }); + } + + async getProjectReviews(projectId: string, includeInternal: boolean) { + const reviews = await this.reviewRepo.find({ + where: { projectId }, + order: { createdAt: 'DESC' }, + relations: ['reviewer'], + }); + + return reviews.map((r) => ({ + id: r.id, + status: r.status, + feedback: r.feedback, + ...(includeInternal ? { internalNote: r.internalNote } : {}), + overrideJustification: r.overrideJustification, + reviewerName: r.reviewer?.name ?? null, + createdAt: r.createdAt, + })); + } + + // ── Unified Airtable duplicate check ── + + /** + * Checks the Unified Airtable "Approved Projects" table for a matching Code URL. + * + * Security constraints: + * - This method is private — only callable within AdminService + * - Only called from getProjectHackatime, which is behind SuperAdminGuard + * - Only accepts HTTPS URLs (rejects anything else) + * - The codeUrl is taken from the project's DB record, never from user input + * - Returns only boolean match/error — no Airtable record data is ever exposed + */ + private async checkUnifiedDuplicate( + codeUrl: string, + ): Promise<{ duplicate: boolean; error: boolean }> { + if (!codeUrl) { + return { duplicate: false, error: true }; + } + + // Only allow https:// URLs — reject anything that could be a formula injection + try { + const parsed = new URL(codeUrl); + if (parsed.protocol !== 'https:') { + return { duplicate: false, error: true }; + } + } catch { + return { duplicate: false, error: true }; + } + + // Escape for Airtable formula: double any backslashes, then escape single quotes + const escaped = codeUrl.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); + const formula = `{Code URL} = '${escaped}'`; + + try { + const params = new URLSearchParams({ + select: JSON.stringify({ + filterByFormula: formula, + maxRecords: 1, + fields: ['Code URL'], + }), + }); + const url = `https://api2.hackclub.com/v0.1/Unified%20YSWS%20Projects%20DB/Approved%20Projects?${params.toString()}`; + this.logger.log(`Unified check: formula=${formula}`); + const res = await fetchWithTimeout(url); + if (!res.ok) { + const body = await res.text(); + this.logger.warn(`Unified check failed (${res.status}): ${body}`); + return { duplicate: false, error: true }; + } + const records: any[] = await res.json() ?? []; + this.logger.log(`Unified check: ${records.length} records found`); + // Only expose match/no-match — never leak record contents + return { duplicate: records.length > 0, error: false }; + } catch { + return { duplicate: false, error: true }; + } + } + + // ── Hackatime admin lookup ── + + private emptyHackatimeResult( + projectId: string, + user: User | null, + isSuperAdmin: boolean, + project?: Project | null, + ) { + return { + projectId, + hackatimeProjects: [], + categories: [], + totalHours: 0, + earliestHeartbeat: null, + previousApprovedHours: project?.overrideHours ?? 0, + previousInternalHours: project?.internalHours ?? 0, + trustLevel: null, + linkedBanned: false, + linkedEmail: null, + linkedSlackUid: null, + beestEmail: isSuperAdmin ? (user?.email ?? null) : null, + beestSlackId: user?.slackId ?? null, + emailMismatch: false, + unifiedDuplicate: false, + unifiedError: true, + }; + } + + private async hackatimeGet(path: string): Promise { + return fetchWithTimeout(`${this.hackatimeBaseUrl}${path}`, { + headers: { Authorization: `Bearer ${this.hackatimeAdminKey}` }, + }); + } + + private async hackatimePost(path: string, body: object): Promise { + return fetchWithTimeout(`${this.hackatimeBaseUrl}${path}`, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.hackatimeAdminKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + } + + async getProjectHackatime(projectId: string, isSuperAdmin: boolean) { + if (!this.hackatimeAdminKey) { + throw new BadRequestException('Hackatime admin API key not configured'); + } + + const project = await this.projectRepo.findOne({ + where: { id: projectId }, + relations: ['user'], + }); + if (!project) throw new NotFoundException('Project not found'); + + const hackatimeNames: string[] = project.hackatimeProjectName ?? []; + const user = project.user; + if (!user) { + return this.emptyHackatimeResult(projectId, user, isSuperAdmin, project); + } + + try { + // 1. Resolve Hackatime user ID — prefer stored ID, fall back to email lookup, then OAuth token + let hackatimeUserId: string | number | null = user.hackatimeUserId ?? null; + if (!hackatimeUserId && user.email) { + try { + const emailRes = await this.hackatimePost( + '/api/admin/v1/user/get_user_by_email', + { email: user.email }, + ); + if (emailRes.ok) { + const emailData = await emailRes.json(); + hackatimeUserId = emailData.user_id ?? emailData?.data?.user_id ?? null; + } + } catch (err) { + this.logger.warn(`Hackatime email lookup failed for project ${projectId}: ${err}`); + } + } + // Last resort: use the user's own Hackatime OAuth token to resolve their ID + if (!hackatimeUserId && user.hackatimeToken) { + try { + const meRes = await fetchWithTimeout( + `${this.hackatimeBaseUrl}/api/v1/authenticated/me`, + { headers: { Authorization: `Bearer ${user.hackatimeToken}` } }, + ); + if (meRes.ok) { + const meData = await meRes.json(); + const d = meData?.data ?? meData; + hackatimeUserId = d?.id?.toString() ?? d?.user_id?.toString() ?? null; + // Persist for future lookups + if (hackatimeUserId) { + user.hackatimeUserId = String(hackatimeUserId); + await this.userRepo.save(user); + } + } + } catch (err) { + this.logger.warn(`Hackatime OAuth /me fallback failed for project ${projectId}: ${err}`); + } + } + if (!hackatimeUserId) { + return this.emptyHackatimeResult(projectId, user, isSuperAdmin, project); + } + + // 2. Get user info (trust level), projects, and Unified duplicate check in parallel + const [infoRes, projectsRes, unifiedResult] = await Promise.all([ + this.hackatimeGet(`/api/admin/v1/user/info?user_id=${hackatimeUserId}`), + this.hackatimeGet(`/api/admin/v1/user/projects?user_id=${hackatimeUserId}`), + this.checkUnifiedDuplicate(project.codeUrl ?? ''), + ]); + + const debug: { infoStatus: number; projectsStatus: number; totalProjectsReturned: number; linkedNames: string[]; availableNames: string[] } = { + infoStatus: infoRes.status, + projectsStatus: projectsRes.status, + totalProjectsReturned: 0, + linkedNames: hackatimeNames, + availableNames: [], + }; + + let trustLevel: string | null = null; + let linkedBanned = false; + let linkedEmail: string | null = null; + let linkedSlackUid: string | null = null; + let emailMismatch = false; + if (infoRes.ok) { + const infoData = await infoRes.json(); + const u = infoData?.user ?? infoData?.data ?? infoData ?? {}; + trustLevel = u?.trust_level ?? u?.trust_factor?.trust_level ?? null; + linkedBanned = u?.banned === true; + linkedSlackUid = typeof u?.slack_uid === 'string' ? u.slack_uid : null; + const rawEmails = u?.email_addresses ?? u?.emails ?? []; + if (Array.isArray(rawEmails) && rawEmails.length > 0) { + const emails = rawEmails.filter( + (e): e is string => typeof e === 'string', + ); + linkedEmail = emails[0] ?? null; + if (user.email) { + const own = user.email.toLowerCase(); + emailMismatch = !emails.some((e) => e.toLowerCase() === own); + } + } + } + + let matched: { name: string; hours: number; languages: string[]; firstHeartbeat: number | null }[] = []; + let categories: { name: string; totalSeconds: number; percent: number }[] = []; + if (projectsRes.ok) { + const projData = await projectsRes.json(); + const allProjects: { + name: string; + total_duration: number; + languages: string[]; + first_heartbeat?: number | string | null; + }[] = projData?.projects ?? projData?.data ?? []; + + debug.totalProjectsReturned = allProjects.length; + debug.availableNames = allProjects.map((p) => p.name); + + if (hackatimeNames.length > 0) { + const nameSet = new Set(hackatimeNames); + const matchedRaw = allProjects.filter((p) => nameSet.has(p.name)); + + // Fetch event-window hours from spans per project: the admin + // /user/projects total_duration is lifetime, which would surface + // pre-event hours to reviewers. end_date is padded by one day so + // today's spans are fully included even with timezone edges. + const endDatePadded = AdminService.ymdUtc( + new Date(Date.now() + 86400_000), + ); + const spansResults = await Promise.allSettled( + matchedRaw.map((p) => + this.hackatimeGet( + `/api/v1/users/${encodeURIComponent(String(hackatimeUserId))}/heartbeats/spans` + + `?start_date=${AdminService.HACKATIME_EVENT_START}&end_date=${endDatePadded}` + + `&project=${encodeURIComponent(p.name)}`, + ).then(async (r) => + r.ok + ? ((await r.json()) as { + spans?: { start_time?: number; end_time?: number; duration?: number }[]; + }) + : null, + ), + ), + ); + + // Pull the Wakatime-compatible categories summary so reviewers can see + // how much of the time was "AI Coding" vs regular coding. One stats + // call covers all linked projects via filter_by_project. + try { + const statsRes = await this.hackatimeGet( + `/api/v1/users/${encodeURIComponent(String(hackatimeUserId))}/stats` + + `?start_date=${AdminService.HACKATIME_EVENT_START}&end_date=${endDatePadded}` + + `&filter_by_project=${encodeURIComponent(matchedRaw.map((p) => p.name).join(','))}`, + ); + if (statsRes.ok) { + const statsBody = await statsRes.json(); + const rawCats = statsBody?.data?.categories ?? statsBody?.categories ?? []; + if (Array.isArray(rawCats)) { + const parsed = rawCats + .map((c: { name?: unknown; total_seconds?: unknown }) => { + const secs = typeof c?.total_seconds === 'number' ? c.total_seconds : Number(c?.total_seconds); + return { + name: typeof c?.name === 'string' ? c.name : '', + totalSeconds: Number.isFinite(secs) && secs > 0 ? secs : 0, + }; + }) + .filter((c) => c.name && c.totalSeconds > 0); + const sum = parsed.reduce((s, c) => s + c.totalSeconds, 0); + if (sum > 0) { + categories = parsed + .map((c) => ({ + name: c.name, + totalSeconds: c.totalSeconds, + percent: Math.round((c.totalSeconds / sum) * 1000) / 10, + })) + .sort((a, b) => b.percent - a.percent); + } + } + } + } catch (err) { + this.logger.warn(`Hackatime category stats failed for project ${projectId}: ${err}`); + } + + matched = matchedRaw.map((p, i) => { + const fhRaw = p.first_heartbeat ?? null; + let firstHeartbeat: number | null = null; + if (fhRaw !== null && fhRaw !== undefined) { + const n = typeof fhRaw === 'string' ? Number(fhRaw) : fhRaw; + if (Number.isFinite(n) && n > 0) { + firstHeartbeat = n > 1e12 ? Math.floor(n / 1000) : Math.floor(n); + } + } + + let seconds = 0; + const sr = spansResults[i]; + if (sr.status === 'fulfilled' && sr.value?.spans) { + for (const span of sr.value.spans) { + if ( + typeof span.duration === 'number' && + Number.isFinite(span.duration) && + span.duration > 0 + ) { + seconds += span.duration; + continue; + } + if ( + typeof span.start_time === 'number' && + typeof span.end_time === 'number' && + Number.isFinite(span.start_time) && + Number.isFinite(span.end_time) && + span.end_time > span.start_time + ) { + // start/end may arrive as seconds or milliseconds. + const diff = span.end_time - span.start_time; + seconds += diff > 1e9 ? diff / 1000 : diff; + } + } + } + + return { + name: p.name, + hours: Math.round((seconds / 3600) * 10) / 10, + languages: p.languages ?? [], + firstHeartbeat, + }; + }); + } + } + + const totalHours = Math.round( + matched.reduce((sum, p) => sum + p.hours, 0) * 10, + ) / 10; + + const heartbeatTimes = matched + .map((p) => p.firstHeartbeat) + .filter((t): t is number => t !== null); + const earliestHeartbeat = heartbeatTimes.length > 0 ? Math.min(...heartbeatTimes) : null; + + // Currently-applied approved hours on the project (the additive base for + // delta-mode review UI). When a project was approved → changes_needed, + // these are 0 even if a historical approved submission exists, signaling + // the FE to switch the input back to cumulative-mode for the next review. + const previousApprovedHours = project.overrideHours ?? 0; + const previousInternalHours = project.internalHours ?? 0; + + return { + projectId, + hackatimeProjects: matched, + categories, + totalHours, + earliestHeartbeat, + previousApprovedHours, + previousInternalHours, + trustLevel, + linkedBanned, + linkedEmail: isSuperAdmin ? linkedEmail : null, + linkedSlackUid, + beestEmail: isSuperAdmin ? (user.email ?? null) : null, + beestSlackId: user.slackId ?? null, + emailMismatch, + unifiedDuplicate: unifiedResult.duplicate, + unifiedError: unifiedResult.error, + debug, + }; + } catch (err) { + this.logger.error(`Hackatime admin lookup error for project ${projectId}: ${err}`); + return { + projectId, + hackatimeProjects: [], + categories: [], + totalHours: 0, + earliestHeartbeat: null, + previousApprovedHours: project.overrideHours ?? 0, + previousInternalHours: project.internalHours ?? 0, + trustLevel: null, + linkedBanned: false, + linkedEmail: null, + linkedSlackUid: null, + beestEmail: null, + beestSlackId: null, + emailMismatch: false, + unifiedDuplicate: false, + unifiedError: true, + }; + } + } + + // ── Unreviewed hours ── + + /** + * Sum of new Hackatime hours awaiting review across every project currently + * in 'unreviewed' status, plus the historical per-decision approval rate and + * a naive prediction of how many of those pending hours will end up approved. + * + * - Hours: for resubmissions the project's previously-approved `overrideHours` + * is subtracted, and event-window /spans are used (not lifetime totals) so + * pre-event Hackatime time is excluded — matches the per-project review UI. + * - Approval rate: across every entry in project_reviews, treats both + * 'changes_needed' and 'ban' as not-approved. + * - Predicted approved hours: totalHours * approvalRate. This is intentionally + * simple — it ignores the fact that 'changes_needed' projects often come + * back and get approved on a later pass, so it's a lower-bound estimate. + */ + async getUnreviewedHours(): Promise<{ + totalHours: number; + projectCount: number; + approvalRate: number; + decisionCount: number; + predictedApprovedHours: number; + }> { + if ( + this.unreviewedHoursCache && + Date.now() - this.unreviewedHoursCache.timestamp < this.UNREVIEWED_HOURS_CACHE_TTL + ) { + return this.unreviewedHoursCache.payload; + } + + // Historical approval rate from project_reviews. Cheap query — single scan + // over a small table — so it shares the unreviewed-hours cache TTL. + const reviewCounts = await this.reviewRepo + .createQueryBuilder('r') + .select("COUNT(*) FILTER (WHERE r.status = 'approved')::int", 'approved') + .addSelect("COUNT(*) FILTER (WHERE r.status = 'changes_needed')::int", 'changesNeeded') + .addSelect("COUNT(*) FILTER (WHERE r.status = 'ban')::int", 'banned') + .getRawOne<{ approved: string | number; changesNeeded: string | number; banned: string | number }>(); + const approved = Number(reviewCounts?.approved ?? 0); + const changesNeeded = Number(reviewCounts?.changesNeeded ?? 0); + const banned = Number(reviewCounts?.banned ?? 0); + const decisionCount = approved + changesNeeded + banned; + const approvalRate = decisionCount > 0 ? approved / decisionCount : 0; + + const projects = await this.projectRepo.find({ + where: { status: 'unreviewed' }, + relations: ['user'], + }); + const projectCount = projects.length; + + if (!this.hackatimeAdminKey || projects.length === 0) { + const payload = { + totalHours: 0, + projectCount, + approvalRate, + decisionCount, + predictedApprovedHours: 0, + }; + this.unreviewedHoursCache = { payload, timestamp: Date.now() }; + return payload; + } + + // Flatten to (project, linked-name) pairs — /spans takes one project name + // per request. Skip projects whose owner has no Hackatime linkage; their + // contribution is 0 either way. + const rows: { hackatimeUserId: string; projectName: string; projectId: string }[] = []; + for (const p of projects) { + if (!p.user?.hackatimeUserId) continue; + if (!p.hackatimeProjectName || p.hackatimeProjectName.length === 0) continue; + for (const name of p.hackatimeProjectName) { + rows.push({ + hackatimeUserId: p.user.hackatimeUserId, + projectName: name, + projectId: p.id, + }); + } + } + + const endDatePadded = AdminService.ymdUtc(new Date(Date.now() + 86400_000)); + const secondsByProject = new Map(); + + const batchSize = 10; + for (let i = 0; i < rows.length; i += batchSize) { + const batch = rows.slice(i, i + batchSize); + await Promise.allSettled( + batch.map(async (row) => { + try { + const res = await this.hackatimeGet( + `/api/v1/users/${encodeURIComponent(row.hackatimeUserId)}/heartbeats/spans` + + `?start_date=${AdminService.HACKATIME_EVENT_START}&end_date=${endDatePadded}` + + `&project=${encodeURIComponent(row.projectName)}`, + ); + if (!res.ok) return; + const data = (await res.json()) as { + spans?: { start_time?: number; end_time?: number; duration?: number }[]; + }; + let seconds = 0; + for (const span of data.spans ?? []) { + if ( + typeof span.duration === 'number' && + Number.isFinite(span.duration) && + span.duration > 0 + ) { + seconds += span.duration; + continue; + } + if ( + typeof span.start_time === 'number' && + typeof span.end_time === 'number' && + Number.isFinite(span.start_time) && + Number.isFinite(span.end_time) && + span.end_time > span.start_time + ) { + const diff = span.end_time - span.start_time; + seconds += diff > 1e9 ? diff / 1000 : diff; + } + } + secondsByProject.set( + row.projectId, + (secondsByProject.get(row.projectId) ?? 0) + seconds, + ); + } catch (err) { + this.logger.warn( + `Unreviewed-hours span fetch failed for project ${row.projectId} (${row.projectName}): ${err}`, + ); + } + }), + ); + } + + let totalHours = 0; + for (const p of projects) { + const hackatimeHours = (secondsByProject.get(p.id) ?? 0) / 3600; + const newHours = Math.max(0, hackatimeHours - (p.overrideHours ?? 0)); + totalHours += newHours; + } + totalHours = Math.round(totalHours * 10) / 10; + const predictedApprovedHours = Math.round(totalHours * approvalRate * 10) / 10; + + const payload = { + totalHours, + projectCount, + approvalRate, + decisionCount, + predictedApprovedHours, + }; + this.unreviewedHoursCache = { payload, timestamp: Date.now() }; + return payload; + } + + // ── Daily Active Users ── + + async getDailyActiveUsers(): Promise<{ count: number }> { + // Return cached value if fresh + if (this.dauCache && Date.now() - this.dauCache.timestamp < this.DAU_CACHE_TTL) { + return { count: this.dauCache.count }; + } + + if (!this.hackatimeAdminKey) { + return { count: 0 }; + } + + // Find all beest projects that are linked to a hackatime project, along with their owner. + // Uses getMany so the hackatimeProjectName JSON transformer runs and decodes the array. + const linkedProjects = await this.projectRepo + .createQueryBuilder('p') + .innerJoinAndSelect('p.user', 'u') + .where('u.hackatime_user_id IS NOT NULL') + .andWhere('p.hackatime_project_name IS NOT NULL') + .select(['p.id', 'p.hackatimeProjectName', 'u.id', 'u.hackatimeUserId']) + .getMany(); + + // Group linked hackatime project names per user + const perUser = new Map< + string, + { hackatimeUserId: string; linkedNames: Set } + >(); + for (const p of linkedProjects) { + if (!p.user?.hackatimeUserId) continue; + if (!p.hackatimeProjectName || p.hackatimeProjectName.length === 0) continue; + let entry = perUser.get(p.user.id); + if (!entry) { + entry = { hackatimeUserId: p.user.hackatimeUserId, linkedNames: new Set() }; + perUser.set(p.user.id, entry); + } + for (const n of p.hackatimeProjectName) entry.linkedNames.add(n); + } + + const users = Array.from(perUser.values()); + const oneDayAgo = Math.floor(Date.now() / 1000) - 86400; + let activeCount = 0; + + // Process in batches of 10 to avoid overwhelming the API + const batchSize = 10; + for (let i = 0; i < users.length; i += batchSize) { + const batch = users.slice(i, i + batchSize); + const results = await Promise.allSettled( + batch.map(async (user) => { + const res = await this.hackatimeGet( + `/api/admin/v1/user/projects?user_id=${user.hackatimeUserId}`, + ); + if (!res.ok) return false; + const data = await res.json(); + const projects: { name?: string; last_heartbeat?: number | string | null }[] = + data?.projects ?? data?.data ?? []; + return projects.some((p) => { + if (!p.name || !user.linkedNames.has(p.name)) return false; + const lh = p.last_heartbeat; + if (lh == null) return false; + const ts = typeof lh === 'string' ? Number(lh) : lh; + if (!Number.isFinite(ts) || ts <= 0) return false; + const normalized = ts > 1e12 ? Math.floor(ts / 1000) : Math.floor(ts); + return normalized >= oneDayAgo; + }); + }), + ); + for (const r of results) { + if (r.status === 'fulfilled' && r.value) activeCount++; + } + } + + this.dauCache = { count: activeCount, timestamp: Date.now() }; + return { count: activeCount }; + } + + /** + * Historical DAU per UTC day, plus the rolling-24h "today" value. + * + * Each past day's count is the number of distinct users whose linked beest + * Hackatime projects produced at least one span with a start in that day's + * UTC window. Finalised days (anything before today UTC) are memoised in + * `dauHistoryCache`, so a typical request only fetches spans for dates not + * yet cached. + */ + async getDauHistory(): Promise<{ + history: { date: string; count: number }[]; + today: { count: number; timestamp: number }; + }> { + const todayUtc = AdminService.ymdUtc(new Date()); + const allDates = AdminService.enumerateDaysUtc(AdminService.DAU_HISTORY_START, todayUtc); + const pastDates = allDates.slice(0, -1); // exclude today — it's the rolling 24h + + if (this.hackatimeAdminKey && pastDates.some((d) => !this.dauHistoryCache.has(d))) { + // Collapse concurrent callers onto a single backfill. + if (!this.dauHistoryInflight) { + this.dauHistoryInflight = this.backfillDauHistory(pastDates).finally(() => { + this.dauHistoryInflight = null; + }); + } + await this.dauHistoryInflight; + } + + const history = pastDates.map((date) => ({ + date, + count: this.dauHistoryCache.get(date) ?? 0, + })); + + const { count: todayCount } = await this.getDailyActiveUsers(); + return { history, today: { count: todayCount, timestamp: Date.now() } }; + } + + private async backfillDauHistory(dates: string[]): Promise { + const missing = dates.filter((d) => !this.dauHistoryCache.has(d)); + if (missing.length === 0) return; + + // Same filter as getDailyActiveUsers: users with a linked Hackatime ID + // and at least one project linked to a Hackatime project name. + const linkedProjects = await this.projectRepo + .createQueryBuilder('p') + .innerJoinAndSelect('p.user', 'u') + .where('u.hackatime_user_id IS NOT NULL') + .andWhere('p.hackatime_project_name IS NOT NULL') + .select(['p.id', 'p.hackatimeProjectName', 'u.id', 'u.hackatimeUserId']) + .getMany(); + + const perUser = new Map }>(); + for (const p of linkedProjects) { + if (!p.user?.hackatimeUserId) continue; + if (!p.hackatimeProjectName || p.hackatimeProjectName.length === 0) continue; + let entry = perUser.get(p.user.id); + if (!entry) { + entry = { hackatimeUserId: p.user.hackatimeUserId, linkedNames: new Set() }; + perUser.set(p.user.id, entry); + } + for (const n of p.hackatimeProjectName) entry.linkedNames.add(n); + } + + const startDate = missing[0]; + const endDate = missing[missing.length - 1]; + // end_date on Hackatime is inclusive of the day — pad by one so the last + // missing day is fully covered even with timezone edge cases. + const endDatePadded = AdminService.ymdUtc( + new Date(Date.parse(endDate + 'T00:00:00Z') + 86400_000), + ); + + // Per-day sets of active user IDs. + const activeByDay = new Map>(); + for (const d of missing) activeByDay.set(d, new Set()); + + const users = Array.from(perUser.entries()); + const batchSize = 10; + for (let i = 0; i < users.length; i += batchSize) { + const batch = users.slice(i, i + batchSize); + await Promise.allSettled( + batch.map(async ([userId, user]) => { + // One request per linked project name — the spans endpoint filters + // by a single project at a time. + const projectNames = Array.from(user.linkedNames); + const responses = await Promise.allSettled( + projectNames.map((name) => + this.hackatimeGet( + `/api/v1/users/${encodeURIComponent(user.hackatimeUserId)}/heartbeats/spans` + + `?start_date=${startDate}&end_date=${endDatePadded}` + + `&project=${encodeURIComponent(name)}`, + ).then(async (r) => (r.ok ? ((await r.json()) as { spans?: { start_time?: number }[] }) : null)), + ), + ); + for (const r of responses) { + if (r.status !== 'fulfilled' || !r.value?.spans) continue; + for (const span of r.value.spans) { + const t = span.start_time; + if (typeof t !== 'number' || !Number.isFinite(t) || t <= 0) continue; + const ms = t > 1e12 ? t : t * 1000; + const day = AdminService.ymdUtc(new Date(ms)); + const set = activeByDay.get(day); + if (set) set.add(userId); + } + } + }), + ); + } + + const todayUtc = AdminService.ymdUtc(new Date()); + for (const [day, set] of activeByDay) { + // Only persist finalised days — today's window is still open. + if (day < todayUtc) this.dauHistoryCache.set(day, set.size); + } + } + + private static ymdUtc(d: Date): string { + const y = d.getUTCFullYear(); + const m = String(d.getUTCMonth() + 1).padStart(2, '0'); + const day = String(d.getUTCDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; + } + + private static enumerateDaysUtc(startYmd: string, endYmd: string): string[] { + const out: string[] = []; + let cursor = Date.parse(startYmd + 'T00:00:00Z'); + const end = Date.parse(endYmd + 'T00:00:00Z'); + while (cursor <= end) { + out.push(AdminService.ymdUtc(new Date(cursor))); + cursor += 86400_000; + } + return out; + } + + // ── Signups + funnel ── + + async getSignupsHistory(): Promise<{ + daily: { date: string; count: number }[]; + cumulative: { date: string; count: number }[]; + total: number; + }> { + if (this.signupsCache && Date.now() - this.signupsCache.timestamp < this.SIGNUPS_CACHE_TTL) { + return this.signupsCache.payload; + } + + const timestamps = await this.rsvpService.getAllSignupTimestamps(); + + const dailyMap = new Map(); + for (const ts of timestamps) { + const day = AdminService.ymdUtc(new Date(ts)); + dailyMap.set(day, (dailyMap.get(day) ?? 0) + 1); + } + + const daily = Array.from(dailyMap.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([date, count]) => ({ date, count })); + + const cumulative: { date: string; count: number }[] = []; + let running = 0; + for (const d of daily) { + running += d.count; + cumulative.push({ date: d.date, count: running }); + } + + const payload = { daily, cumulative, total: timestamps.length }; + this.signupsCache = { payload, timestamp: Date.now() }; + return payload; + } + + async getUserFunnel(): Promise<{ + signedUp: number; + loggedIn: number; + linkedHackatime: number; + submittedProject: number; + approvedProject: number; + madeOrder: number; + }> { + const signupsHistory = await this.getSignupsHistory().catch(() => null); + + // "approvedProject" counts users with a durable approval event in the + // submissions table rather than users whose CURRENT project is in the + // 'approved' state. Project status drifts: a reviewer approval first moves + // the project to 'fraud_pending' (and only the fraud poller flips it to + // 'approved'), and an approved → changes_needed transition wipes the + // approved status entirely. Submissions, by contrast, are immutable history + // — so counting distinct users with at least one approved submission + // captures everyone who's ever been approved. + const [loggedIn, linkedHackatime, submittedRaw, approvedRaw, orderRaw] = await Promise.all([ + this.userRepo.count(), + this.userRepo + .createQueryBuilder('u') + .where('u.hackatime_user_id IS NOT NULL') + .getCount(), + this.projectRepo + .createQueryBuilder('p') + .select('COUNT(DISTINCT p.user_id)', 'c') + .getRawOne<{ c: string }>(), + this.submissionRepo + .createQueryBuilder('s') + .select('COUNT(DISTINCT s.user_id)', 'c') + .where('s.status = :status', { status: 'approved' }) + .getRawOne<{ c: string }>(), + this.orderRepo + .createQueryBuilder('o') + .select('COUNT(DISTINCT o.user_id)', 'c') + .getRawOne<{ c: string }>(), + ]); + + return { + signedUp: signupsHistory?.total ?? 0, + loggedIn, + linkedHackatime, + submittedProject: Number(submittedRaw?.c ?? 0), + approvedProject: Number(approvedRaw?.c ?? 0), + madeOrder: Number(orderRaw?.c ?? 0), + }; + } + + // ── News CRUD ── + + async listNews(): Promise { + return this.newsRepo.find({ order: { displayDate: 'DESC', createdAt: 'DESC' } }); + } + + async createNews(text: string, displayDate: string): Promise { + const item = this.newsRepo.create({ text, displayDate }); + return this.newsRepo.save(item); + } + + async updateNews(id: string, data: { text?: string; displayDate?: string }): Promise { + const item = await this.newsRepo.findOne({ where: { id } }); + if (!item) throw new NotFoundException('News item not found'); + if (data.text !== undefined) item.text = data.text; + if (data.displayDate !== undefined) item.displayDate = data.displayDate; + return this.newsRepo.save(item); + } + + async deleteNews(id: string): Promise { + const item = await this.newsRepo.findOne({ where: { id } }); + if (!item) throw new NotFoundException('News item not found'); + await this.newsRepo.remove(item); + } + + // ── Shop CRUD ── + + async listShopItems(): Promise { + return this.shopRepo.find({ order: { sortOrder: 'ASC' } }); + } + + async createShopItem(data: { + name: string; + description: string; + detailedDescription?: string | null; + imageUrl: string; + priceHours: number; + stock?: number | null; + estimatedShip?: string | null; + isActive?: boolean; + isFeatured?: boolean; + }): Promise { + const maxOrder = await this.shopRepo + .createQueryBuilder('s') + .select('MAX(s.sortOrder)', 'max') + .getRawOne(); + const sortOrder = (maxOrder?.max ?? -1) + 1; + + const item = this.shopRepo.create({ + name: data.name, + description: data.description, + detailedDescription: data.detailedDescription ?? null, + imageUrl: data.imageUrl, + priceHours: data.priceHours, + stock: data.stock ?? null, + estimatedShip: data.estimatedShip ?? null, + isActive: data.isActive ?? true, + isFeatured: data.isFeatured ?? false, + sortOrder, + }); + return this.shopRepo.save(item); + } + + async updateShopItem(id: string, data: { + name?: string; + description?: string; + detailedDescription?: string | null; + imageUrl?: string; + priceHours?: number; + stock?: number | null; + estimatedShip?: string | null; + isActive?: boolean; + isFeatured?: boolean; + }): Promise { + const item = await this.shopRepo.findOne({ where: { id } }); + if (!item) throw new NotFoundException('Shop item not found'); + if (data.name !== undefined) item.name = data.name; + if (data.description !== undefined) item.description = data.description; + if (data.detailedDescription !== undefined) item.detailedDescription = data.detailedDescription; + if (data.imageUrl !== undefined) item.imageUrl = data.imageUrl; + if (data.priceHours !== undefined) item.priceHours = data.priceHours; + if (data.stock !== undefined) item.stock = data.stock; + if (data.estimatedShip !== undefined) item.estimatedShip = data.estimatedShip; + if (data.isActive !== undefined) item.isActive = data.isActive; + if (data.isFeatured !== undefined) item.isFeatured = data.isFeatured; + return this.shopRepo.save(item); + } + + async deleteShopItem(id: string): Promise { + const item = await this.shopRepo.findOne({ where: { id } }); + if (!item) throw new NotFoundException('Shop item not found'); + await this.shopRepo.remove(item); + } + + async reorderShopItems(items: { id: string; sortOrder: number }[]): Promise { + await Promise.all( + items.map((i) => this.shopRepo.update(i.id, { sortOrder: i.sortOrder })), + ); + } + + /** + * Super-admin-only order detail for fulfillment: returns the buyer's address + * (fetched live from HCA — never persisted in beest) plus their approved + * projects so fulfillment staff can verify what to ship. + */ + async getOrderDetailForFulfillment(orderId: string): Promise<{ + address: { + streetAddress: string | null; + locality: string | null; + region: string | null; + postalCode: string | null; + country: string | null; + } | null; + addressMissing: boolean; + projects: { + id: string; + name: string; + projectType: string | null; + hours: number | null; + approvedAt: string; + }[]; + }> { + const order = await this.orderRepo.findOne({ + where: { id: orderId }, + relations: ['user'], + }); + if (!order) throw new NotFoundException('Order not found'); + + const user = order.user; + + const [identity, projects] = await Promise.all([ + user?.hcaSub ? this.hcaService.getIdentity(user.hcaSub) : Promise.resolve(null), + this.projectRepo + .createQueryBuilder('project') + .where('project.userId = :uid', { uid: order.userId }) + .andWhere('project.status = :status', { status: 'approved' }) + .select([ + 'project.id', + 'project.name', + 'project.projectType', + 'project.overrideHours', + 'project.updatedAt', + ]) + .orderBy('project.updatedAt', 'DESC') + .getMany(), + ]); + + const addr = identity?.address ?? null; + const address = addr + ? { + streetAddress: addr.street_address ?? null, + locality: addr.locality ?? null, + region: addr.region ?? null, + postalCode: addr.postal_code ?? null, + country: addr.country ?? null, + } + : null; + + return { + address, + addressMissing: !address, + projects: projects.map((p) => ({ + id: p.id, + name: p.name, + projectType: p.projectType ?? null, + hours: p.overrideHours ?? null, + approvedAt: p.updatedAt.toISOString(), + })), + }; + } +} diff --git a/backend/src/admin/audit.service.ts b/backend/src/admin/audit.service.ts new file mode 100644 index 0000000..245e036 --- /dev/null +++ b/backend/src/admin/audit.service.ts @@ -0,0 +1,775 @@ +import { + Injectable, + Logger, + BadRequestException, + NotFoundException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, Repository } from 'typeorm'; +import { Project } from '../entities/project.entity'; +import { Submission } from '../entities/submission.entity'; +import { ProjectReview } from '../entities/project-review.entity'; +import { User } from '../entities/user.entity'; +import { AuditLogService } from '../audit-log/audit-log.service'; +import { RsvpService } from '../rsvp/rsvp.service'; +import { ProjectAirtableSyncService } from '../projects/project-airtable-sync.service'; +import { fetchWithTimeout } from '../fetch.util'; +import { analyzeActivity, type Heartbeat } from './activity-stats'; +import { AdminService } from './admin.service'; +import { Inject, forwardRef } from '@nestjs/common'; + +// Beest event start — Hackatime time before this date is ignored, matching the +// rest of the admin Hackatime tooling. +const HACKATIME_EVENT_START = '2026-04-02'; +const MAX_LOOKBACK_DAYS = 70; + +export type AuditAction = 'approve' | 'rereview' | 'reject' | 'ban'; + +export interface AuditDecisionDto { + action: AuditAction; + // approve + overrideHours?: number | null; + internalHours?: number | null; + justification?: string | null; + // rereview (feedback to the first reviewer, internal) + reviewerFeedback?: string | null; + // reject + ban (feedback to the user) + userFeedback?: string | null; + // ban only: caller must be Super Admin — the controller passes this through + // from the resolved request perms so the service can refuse non-SA bans. + isSuperAdmin?: boolean; +} + +function parseHackatimeNames(raw: string | string[] | null | undefined): string[] { + if (!raw) return []; + // The Project entity transforms this column to a string[] already, but guard + // against a raw string (JSON or comma-separated) just in case. + if (Array.isArray(raw)) { + return raw.map((s) => String(s).trim()).filter(Boolean); + } + const trimmed = raw.trim(); + if (!trimmed) return []; + try { + const parsed = JSON.parse(trimmed); + if (Array.isArray(parsed)) { + return parsed.map((s) => String(s).trim()).filter(Boolean); + } + if (typeof parsed === 'string') return [parsed.trim()].filter(Boolean); + } catch { + // not JSON — fall back to comma-separated + } + return trimmed + .split(',') + .map((s) => s.trim()) + .filter(Boolean); +} + +@Injectable() +export class AuditService { + private readonly logger = new Logger(AuditService.name); + private readonly hackatimeBaseUrl: string; + + constructor( + private readonly config: ConfigService, + @InjectRepository(Project) private readonly projectRepo: Repository, + @InjectRepository(Submission) + private readonly submissionRepo: Repository, + @InjectRepository(ProjectReview) + private readonly reviewRepo: Repository, + @InjectRepository(User) private readonly userRepo: Repository, + private readonly auditLogService: AuditLogService, + private readonly rsvpService: RsvpService, + private readonly airtableSync: ProjectAirtableSyncService, + @Inject(forwardRef(() => AdminService)) + private readonly adminService: AdminService, + ) { + this.hackatimeBaseUrl = this.config.get( + 'HACKATIME_BASE_URL', + 'https://hackatime.hackclub.com', + ); + } + + // ── Queue ────────────────────────────────────────────────────────────────── + + /** + * Projects awaiting a super-admin second pass. These are first-reviewer + * approved projects parked in `fraud_pending` (the old fraud-review holding + * state, now repurposed as the second-pass queue). Oldest first. + */ + async listQueue(): Promise { + const projects = await this.projectRepo.find({ + where: { status: 'fraud_pending' }, + relations: ['user'], + order: { createdAt: 'ASC' }, + }); + + return Promise.all(projects.map((p) => this.serializeQueueItem(p))); + } + + /** + * Pull up to N oldest unreviewed projects into the audit queue so a super + * admin can clear them as one-shot reviews (skipping the first-pass stage). + * + * Constraint: only allowed when the queue is empty — this is meant to bridge + * a temporary first-reviewer shortage, not run as a parallel review stream. + * The "one-shot" property is inferred at decide-time by checking that no + * prior `ProjectReview` with status='approved' exists for the project; we + * don't tag the row, we just rely on the absence of a first-pass approval. + */ + async loadUnreviewedIntoQueue(superAdminId: string, limit = 10): Promise<{ loaded: number }> { + const pending = await this.projectRepo.count({ where: { status: 'fraud_pending' } }); + if (pending > 0) { + throw new BadRequestException( + `Cannot load unreviewed projects — audit queue is not empty (${pending} project${pending === 1 ? '' : 's'} still pending).`, + ); + } + + const safeLimit = Math.max(1, Math.min(limit, 25)); + const candidates = await this.projectRepo.find({ + where: { status: 'unreviewed' }, + order: { createdAt: 'ASC' }, + take: safeLimit, + }); + if (candidates.length === 0) { + return { loaded: 0 }; + } + + for (const p of candidates) { + p.status = 'fraud_pending'; + } + await this.projectRepo.save(candidates); + + await this.auditLogService.log( + superAdminId, + 'project_reviewed', + `Loaded ${candidates.length} unreviewed project${candidates.length === 1 ? '' : 's'} into the audit queue for one-shot review`, + ); + this.logger.log( + `Super admin ${superAdminId} loaded ${candidates.length} unreviewed projects into the audit queue`, + ); + return { loaded: candidates.length }; + } + + private async serializeQueueItem(project: Project) { + // Pull every submission for this project so we can show the SA the full + // history (approved hours + reasons + reviewer) for resubmissions. The + // current submission is the newest one; everything older goes into + // priorSubmissions for the UI to render as a timeline. + const allSubmissions = await this.submissionRepo.find({ + where: { projectId: project.id }, + order: { createdAt: 'DESC' }, + }); + const submission = allSubmissions[0] ?? null; + const olderSubmissions = allSubmissions.slice(1); + + const submissionIds = allSubmissions.map((s) => s.id); + const allReviews = submissionIds.length + ? await this.reviewRepo.find({ + where: { submissionId: In(submissionIds) }, + order: { createdAt: 'DESC' }, + }) + : []; + + const reviewsBySubmission = new Map(); + for (const r of allReviews) { + if (!r.submissionId) continue; + const list = reviewsBySubmission.get(r.submissionId) ?? []; + list.push(r); + reviewsBySubmission.set(r.submissionId, list); + } + + // Submission-scoped one-shot detection: did the first-pass approve THIS + // submission? A re-ship whose new submission has never been approved + // correctly registers as one-shot when loaded. + const currentReviews = submission + ? reviewsBySubmission.get(submission.id) ?? [] + : []; + const originalApproval = + currentReviews.find((r) => r.status === 'approved') ?? null; + + const reviewerIds = Array.from( + new Set( + allReviews + .map((r) => r.reviewerId) + .filter((id): id is string => !!id), + ), + ); + const reviewers = reviewerIds.length + ? await this.userRepo.find({ where: { id: In(reviewerIds) } }) + : []; + const reviewerById = new Map(reviewers.map((u) => [u.id, u])); + const nameOf = (reviewerId: string | null | undefined): string | null => { + if (!reviewerId) return null; + const u = reviewerById.get(reviewerId); + return u?.nickname || u?.name || null; + }; + + const reviewerName = nameOf(originalApproval?.reviewerId); + + const priorSubmissions = olderSubmissions.map((sub) => { + const subReviews = reviewsBySubmission.get(sub.id) ?? []; + // Latest review (already DESC sorted) — the one that decided this submission + const latest = subReviews[0] ?? null; + return { + id: sub.id, + status: sub.status, + overrideHours: sub.overrideHours, + internalHours: sub.internalHours, + pipesGranted: sub.pipesGranted, + changeDescription: sub.changeDescription, + createdAt: sub.createdAt, + review: latest + ? { + status: latest.status, + overrideJustification: latest.overrideJustification, + feedback: latest.feedback, + internalNote: latest.internalNote, + reviewerName: nameOf(latest.reviewerId), + createdAt: latest.createdAt, + } + : null, + }; + }); + + const user = project.user; + return { + id: project.id, + name: project.name, + description: project.description, + projectType: project.projectType, + codeUrl: project.codeUrl, + readmeUrl: project.readmeUrl, + demoUrl: project.demoUrl, + screenshot1Url: project.screenshot1Url, + screenshot2Url: project.screenshot2Url, + hackatimeProjectNames: parseHackatimeNames(project.hackatimeProjectName), + aiUse: project.aiUse, + isUpdate: project.isUpdate, + otherHcProgram: project.otherHcProgram, + overrideHours: project.overrideHours ?? 0, + internalHours: project.internalHours ?? 0, + pipesGranted: project.pipesGranted ?? 0, + createdAt: project.createdAt, + owner: user + ? { + id: user.id, + name: user.name, + nickname: user.nickname, + slackId: user.slackId, + email: user.email, + hackatimeConnected: !!user.hackatimeToken, + } + : null, + originalApproval: originalApproval + ? { + reviewerId: originalApproval.reviewerId, + reviewerName, + overrideJustification: originalApproval.overrideJustification, + feedback: originalApproval.feedback, + internalNote: originalApproval.internalNote, + createdAt: originalApproval.createdAt, + } + : null, + // One-shot = pulled into the queue via the SA escape hatch (no prior + // first-pass approval). The decide endpoint enforces stricter checks in + // this mode; the UI surfaces it so the SA knows they're the only review. + isOneShot: !originalApproval, + submission: submission + ? { + id: submission.id, + changeDescription: submission.changeDescription, + overrideHours: submission.overrideHours, + createdAt: submission.createdAt, + } + : null, + // Older submissions on the same project, newest first, each with its + // latest review (so the SA can see history for resubmissions). Empty + // array for first ships. + priorSubmissions, + }; + } + + // ── Decision ─────────────────────────────────────────────────────────────-- + + async decide( + projectId: string, + superAdminId: string, + dto: AuditDecisionDto, + ): Promise<{ success: true }> { + const project = await this.projectRepo.findOne({ + where: { id: projectId }, + relations: ['user'], + }); + if (!project) throw new NotFoundException('Project not found'); + if (project.status !== 'fraud_pending') { + throw new BadRequestException( + `Project is not awaiting second review (status: ${project.status}).`, + ); + } + + const submission = await this.submissionRepo.findOne({ + where: { projectId }, + order: { createdAt: 'DESC' }, + }); + + switch (dto.action) { + case 'approve': + return this.approve(project, submission, superAdminId, dto); + case 'rereview': + return this.returnForReReview(project, submission, superAdminId, dto); + case 'reject': + return this.reject(project, submission, superAdminId, dto); + case 'ban': + return this.banFromAudit(project, superAdminId, dto); + default: + throw new BadRequestException('Unknown action'); + } + } + + /** Ban-and-reject — Super-Admin only. Delegates to AdminService so the ban + * path stays identical to the first-pass ban. */ + private async banFromAudit( + project: Project, + superAdminId: string, + dto: AuditDecisionDto, + ): Promise<{ success: true }> { + if (!dto.isSuperAdmin) { + throw new BadRequestException( + 'Only Super Admins can ban from the audit panel.', + ); + } + const feedback = (dto.userFeedback ?? '').trim(); + if (feedback.length < 10) { + throw new BadRequestException( + 'Ban feedback for the user must be at least 10 characters.', + ); + } + await this.adminService.banAndRejectProject( + project.id, + superAdminId, + feedback, + 'Banned via audit panel', + null, + ); + return { success: true }; + } + + /** Final approval: grant pipes + push to Airtable (relocated from the old + * fraud-review poller's completeApproval). */ + private async approve( + project: Project, + submission: Submission | null, + superAdminId: string, + dto: AuditDecisionDto, + ): Promise<{ success: true }> { + // One-shot mode: this *submission* has no first-pass approval, so this is + // the only review it will get. Tighten the justification floor and apply + // the same Hackatime cap the first-pass would have enforced. Submission- + // scoped so re-ships of previously-approved projects still register. + const priorApproval = submission + ? await this.reviewRepo.findOne({ + where: { submissionId: submission.id, status: 'approved' }, + }) + : null; + const isOneShot = !priorApproval; + + const justification = (dto.justification ?? '').trim(); + const minJustification = isOneShot ? 250 : 50; + if (justification.length < minJustification) { + throw new BadRequestException( + `Approval justification must be at least ${minJustification} characters.`, + ); + } + + // The SA may set overrideHours (user-facing, drives pipes) and internalHours + // (Airtable's "Override Hours Spent") independently. Both are treated as + // FINAL cumulative values, overwriting the first reviewer's numbers. If + // internalHours isn't supplied the existing project value is preserved, + // EXCEPT in one-shot mode where it defaults to the override value (no + // first-pass left an internalHours behind). + if (dto.overrideHours !== null && dto.overrideHours !== undefined) { + const finalOverride = Math.round(dto.overrideHours * 10) / 10; + if (!Number.isFinite(finalOverride) || finalOverride <= 0) { + throw new BadRequestException('overrideHours must be a positive number.'); + } + if (finalOverride < (project.pipesGranted ?? 0)) { + throw new BadRequestException( + `Cannot reduce hours to ${finalOverride} — ${project.pipesGranted} pipes already granted.`, + ); + } + // One-shot: enforce the same Hackatime cap the first-pass would apply. + // Cap = currentHackatime + 0.5h rounding buffer (previousProjectHours is + // 0 since first-pass never ran). Hackatime outage is logged and skipped + // rather than blocking the review. + if (isOneShot) { + try { + const ht = await this.adminService.getProjectHackatime(project.id, false); + const currentHackatime = ht?.totalHours ?? 0; + const allowedDelta = currentHackatime + 0.5; + if (finalOverride > allowedDelta) { + const hackatimeHours = Math.round(currentHackatime * 10) / 10; + throw new BadRequestException( + `Cannot approve ${finalOverride}h — Hackatime shows only ${hackatimeHours}h. Reduce the approved hours, or reject instead.`, + ); + } + } catch (e) { + if (e instanceof BadRequestException) throw e; + this.logger.warn( + `One-shot Hackatime cap check failed for project ${project.id}: ${e}`, + ); + } + } + project.overrideHours = finalOverride; + if (submission) submission.overrideHours = finalOverride; + + let finalInternal: number; + if (dto.internalHours !== null && dto.internalHours !== undefined) { + finalInternal = Math.round(dto.internalHours * 10) / 10; + if (!Number.isFinite(finalInternal) || finalInternal < 0) { + throw new BadRequestException('internalHours must be a non-negative number.'); + } + } else { + finalInternal = isOneShot ? finalOverride : (project.internalHours ?? finalOverride); + } + project.internalHours = finalInternal; + if (submission) submission.internalHours = finalInternal; + } else if (isOneShot) { + // One-shot requires the SA to explicitly set the approved hours — the + // project has no first-pass-set value to inherit from. + throw new BadRequestException( + 'One-shot approval requires overrideHours to be set.', + ); + } + + project.status = 'approved'; + await this.projectRepo.save(project); + + if (submission && submission.status !== 'approved') { + submission.status = 'approved'; + await this.submissionRepo.save(submission); + } + + // Grant pipes — delta logic identical to the previous fraud poller path. + if ((project.overrideHours ?? 0) > 0) { + const totals = await this.projectRepo + .createQueryBuilder('p') + .select('COALESCE(SUM(p.override_hours), 0)', 'earnedHours') + .addSelect('COALESCE(SUM(p.pipes_granted), 0)', 'granted') + .where('p.user_id = :uid', { uid: project.userId }) + .andWhere( + `(p.status = 'approved' OR (p.status <> 'approved' AND p.pipes_granted > 0))`, + ) + .getRawOne<{ earnedHours: string; granted: string }>(); + const target = Math.floor(Number(totals?.earnedHours ?? 0)); + const previouslyGranted = Number(totals?.granted ?? 0); + const delta = target - previouslyGranted; + if (delta > 0) { + await this.userRepo.increment({ id: project.userId }, 'pipes', delta); + project.pipesGranted = (project.pipesGranted ?? 0) + delta; + await this.projectRepo.save(project); + if (submission) { + submission.pipesGranted = delta; + await this.submissionRepo.save(submission); + } + } + } + + // Record the second-pass approval. One-shot approvals can carry a + // user-facing feedback string (the only review the user will see, so the + // SA may want to leave a note); regular second-pass approvals don't. + const approveUserFeedback = isOneShot + ? ((dto.userFeedback ?? '').trim() || null) + : null; + const review = this.reviewRepo.create({ + projectId: project.id, + reviewerId: superAdminId, + submissionId: submission?.id ?? null, + status: 'approved', + feedback: approveUserFeedback, + internalNote: isOneShot ? 'One-shot approval' : 'Second-pass (super admin) approval', + overrideJustification: justification, + }); + await this.reviewRepo.save(review); + + // Loops + Airtable Projects push — now gated to this stage only. + if (project.user?.email) { + this.rsvpService.updateDateField( + project.user.email, + 'Loops - beestApprovedProject', + ); + } + try { + await this.airtableSync.syncApprovedProject( + project, + justification, + submission ?? null, + ); + } catch (err) { + this.logger.error( + `Airtable sync failed for second-pass-approved project ${project.id}: ${err}`, + ); + } + + await this.auditLogService.log( + project.userId, + 'project_reviewed', + `Project "${project.name}" was approved`, + ); + this.logger.log(`Second-pass approved project ${project.id}`); + return { success: true }; + } + + /** Send back to the first-review queue with feedback for the first reviewer + * (internal — the user is not notified). */ + private async returnForReReview( + project: Project, + submission: Submission | null, + superAdminId: string, + dto: AuditDecisionDto, + ): Promise<{ success: true }> { + const feedback = (dto.reviewerFeedback ?? '').trim(); + if (feedback.length < 10) { + throw new BadRequestException( + 'Re-review feedback must be at least 10 characters.', + ); + } + + // Revert the pending approval delta this submission contributed, so a + // re-approval re-adds it cleanly rather than double-counting. + const subOverride = submission?.overrideHours ?? 0; + const subInternal = submission?.internalHours ?? 0; + if (subOverride > 0) { + project.overrideHours = Math.max( + 0, + Math.round(((project.overrideHours ?? 0) - subOverride) * 10) / 10, + ); + } + if (subInternal > 0) { + project.internalHours = Math.max( + 0, + Math.round(((project.internalHours ?? 0) - subInternal) * 10) / 10, + ); + } + project.status = 'unreviewed'; + await this.projectRepo.save(project); + + if (submission) { + submission.status = 'unreviewed'; + submission.reviewerNote = `[Returned by second-pass review] ${feedback}`; + await this.submissionRepo.save(submission); + } + + // Internal trace (logged against the super admin, not the user). + await this.auditLogService.log( + superAdminId, + 'project_reviewed', + `Returned "${project.name}" to the first-review queue for re-review`, + ); + this.logger.log(`Second-pass returned project ${project.id} for re-review`); + return { success: true }; + } + + /** Regular rejection — user-facing changes-needed, no pipes (none granted). */ + private async reject( + project: Project, + submission: Submission | null, + superAdminId: string, + dto: AuditDecisionDto, + ): Promise<{ success: true }> { + const feedback = (dto.userFeedback ?? '').trim(); + if (feedback.length < 10) { + throw new BadRequestException( + 'Rejection feedback for the user must be at least 10 characters.', + ); + } + + project.status = 'changes_needed'; + project.overrideHours = 0; + project.internalHours = 0; + await this.projectRepo.save(project); + + if (submission && submission.status !== 'changes_needed') { + submission.status = 'changes_needed'; + submission.overrideHours = 0; + submission.internalHours = 0; + await this.submissionRepo.save(submission); + } + + const review = this.reviewRepo.create({ + projectId: project.id, + reviewerId: superAdminId, + submissionId: submission?.id ?? null, + status: 'changes_needed', + feedback, + internalNote: 'Rejected at second-pass review', + overrideJustification: null, + }); + await this.reviewRepo.save(review); + + await this.auditLogService.log( + project.userId, + 'project_reviewed', + `Project "${project.name}" received feedback`, + ); + this.logger.log(`Second-pass rejected project ${project.id}`); + return { success: true }; + } + + // ── Heartbeats (anomaly + activity analysis) ─────────────────────────────── + + /** + * Streams NDJSON events ({type: meta|day|complete|error}) for the heartbeat + * timeline + anomaly analysis. Fetches the owner's raw Hackatime heartbeats + * day-by-day using their stored OAuth token, filters to the project's linked + * Hackatime project names, then analyzes. + */ + async *streamActivityEvents( + projectId: string, + ): AsyncGenerator> { + const project = await this.projectRepo.findOne({ + where: { id: projectId }, + relations: ['user'], + }); + if (!project) { + yield { type: 'error', error: 'project-not-found' }; + return; + } + const user = project.user; + if (!user) { + yield { type: 'error', error: 'owner-not-found' }; + return; + } + const names = parseHackatimeNames(project.hackatimeProjectName); + if (names.length === 0) { + yield { type: 'error', error: 'no-hackatime-project' }; + return; + } + if (!user.hackatimeToken) { + yield { type: 'error', error: 'owner-not-linked' }; + return; + } + + let apiKey: string; + try { + apiKey = await this.getApiKey(user.hackatimeToken); + } catch (err) { + this.logger.warn(`Hackatime api_key fetch failed for ${projectId}: ${err}`); + yield { type: 'error', error: 'hackatime-auth-failed' }; + return; + } + + const to = new Date(); + let fromMs = Date.parse(`${HACKATIME_EVENT_START}T00:00:00Z`); + const ceilingMs = to.getTime() - MAX_LOOKBACK_DAYS * 86_400_000; + if (Number.isNaN(fromMs)) fromMs = ceilingMs; + fromMs = Math.max(fromMs, ceilingMs); + const from = new Date(fromMs); + const days = eachDayUTC(from, to); + + yield { + type: 'meta', + hackatimeProjects: names, + fromIso: from.toISOString(), + toIso: to.toISOString(), + daysQueried: days.length, + }; + + const projectSet = new Set(names); + const matched: Heartbeat[] = []; + const seen = new Set(); + let rawCount = 0; + let daysSeen = 0; + const concurrency = 6; + + for (let i = 0; i < days.length; i += concurrency) { + const batch = days.slice(i, i + concurrency); + const results = await Promise.allSettled( + batch.map((d) => this.fetchHeartbeatsForDay(apiKey, d)), + ); + for (let j = 0; j < batch.length; j++) { + daysSeen += 1; + const r = results[j]; + const hbs = r.status === 'fulfilled' ? r.value : []; + for (const hb of hbs) { + const key = `${hb.time}|${hb.entity}`; + if (seen.has(key)) continue; + seen.add(key); + rawCount += 1; + if (projectSet.has((hb.project ?? '').trim())) matched.push(hb); + } + yield { + type: 'day', + day: batch[j].toISOString().slice(0, 10), + daysSeen, + daysTotal: days.length, + matchedCountSoFar: matched.length, + rawCountSoFar: rawCount, + }; + } + } + + const analysis = analyzeActivity(matched); + yield { + type: 'complete', + analysis, + hackatimeProjects: names, + diagnostics: { + rawCount, + matchedCount: matched.length, + daysQueried: days.length, + }, + }; + } + + private async getApiKey(ownerToken: string): Promise { + const res = await fetchWithTimeout( + `${this.hackatimeBaseUrl}/api/v1/authenticated/api_keys`, + { headers: { Authorization: `Bearer ${ownerToken}` } }, + ); + if (!res.ok) { + throw new Error(`api_keys ${res.status}`); + } + const body = (await res.json()) as { token?: string }; + if (!body.token) throw new Error('api_keys response missing token'); + return body.token; + } + + private async fetchHeartbeatsForDay( + apiKey: string, + day: Date, + ): Promise { + const start = new Date( + Date.UTC(day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate(), 0, 0, 0), + ); + const end = new Date( + Date.UTC(day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate(), 23, 59, 59), + ); + const params = new URLSearchParams({ + start_time: start.toISOString(), + end_time: end.toISOString(), + }); + const res = await fetchWithTimeout( + `${this.hackatimeBaseUrl}/api/v1/my/heartbeats?${params.toString()}`, + { headers: { Authorization: `Bearer ${apiKey}` } }, + ); + if (!res.ok) return []; + const body = (await res.json()) as { heartbeats?: Heartbeat[] }; + return Array.isArray(body.heartbeats) ? body.heartbeats : []; + } +} + +function eachDayUTC(from: Date, to: Date): Date[] { + const days: Date[] = []; + const cursor = new Date( + Date.UTC(from.getUTCFullYear(), from.getUTCMonth(), from.getUTCDate()), + ); + const last = new Date( + Date.UTC(to.getUTCFullYear(), to.getUTCMonth(), to.getUTCDate()), + ); + while (cursor.getTime() <= last.getTime()) { + days.push(new Date(cursor.getTime())); + cursor.setUTCDate(cursor.getUTCDate() + 1); + } + return days; +} diff --git a/backend/src/admin/fraud-reviewer.guard.ts b/backend/src/admin/fraud-reviewer.guard.ts new file mode 100644 index 0000000..c2951a1 --- /dev/null +++ b/backend/src/admin/fraud-reviewer.guard.ts @@ -0,0 +1,52 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + ForbiddenException, + UnauthorizedException, +} from '@nestjs/common'; +import { AuthService } from '../auth/auth.service'; +import { RsvpService } from '../rsvp/rsvp.service'; + +const ALLOWED_ROLES = ['Super Admin', 'Fraud Reviewer']; + +/** + * Guard for routes that gate audit-panel actions and bans. + * Allows Super Admin and Fraud Reviewer. Checks Airtable on every request — + * no caching, so revocations are instant. + */ +@Injectable() +export class FraudReviewerGuard implements CanActivate { + constructor( + private readonly authService: AuthService, + private readonly rsvpService: RsvpService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const authHeader = request.headers.authorization; + + if (!authHeader?.startsWith('Bearer ')) { + throw new UnauthorizedException(); + } + + let user: Record; + try { + const token = authHeader.split(' ')[1]; + user = this.authService.verifyToken(token); + } catch { + throw new UnauthorizedException(); + } + + const email = user?.email as string; + if (!email) throw new ForbiddenException(); + + const perms = await this.rsvpService.getPerms(email); + if (!perms || !ALLOWED_ROLES.includes(perms)) { + throw new ForbiddenException(); + } + + request.user = { ...user, perms }; + return true; + } +} diff --git a/backend/src/admin/reviewer.guard.ts b/backend/src/admin/reviewer.guard.ts new file mode 100644 index 0000000..9dd4613 --- /dev/null +++ b/backend/src/admin/reviewer.guard.ts @@ -0,0 +1,51 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + ForbiddenException, + UnauthorizedException, +} from '@nestjs/common'; +import { AuthService } from '../auth/auth.service'; +import { RsvpService } from '../rsvp/rsvp.service'; + +const REVIEWER_ROLES = ['Super Admin', 'Reviewer', 'Fraud Reviewer']; + +/** + * Guard that requires a valid JWT AND Reviewer-level+ Perms in Airtable. + * Allows Super Admin, Reviewer, and Fraud Reviewer. + */ +@Injectable() +export class ReviewerGuard implements CanActivate { + constructor( + private readonly authService: AuthService, + private readonly rsvpService: RsvpService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const authHeader = request.headers.authorization; + + if (!authHeader?.startsWith('Bearer ')) { + throw new UnauthorizedException(); + } + + let user: Record; + try { + const token = authHeader.split(' ')[1]; + user = this.authService.verifyToken(token); + } catch { + throw new UnauthorizedException(); + } + + const email = user?.email as string; + if (!email) throw new ForbiddenException(); + + const perms = await this.rsvpService.getPerms(email); + if (!perms || !REVIEWER_ROLES.includes(perms)) { + throw new ForbiddenException(); + } + + request.user = { ...user, perms }; + return true; + } +} diff --git a/backend/src/admin/super-admin.guard.ts b/backend/src/admin/super-admin.guard.ts new file mode 100644 index 0000000..89998f6 --- /dev/null +++ b/backend/src/admin/super-admin.guard.ts @@ -0,0 +1,49 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + ForbiddenException, + UnauthorizedException, +} from '@nestjs/common'; +import { AuthService } from '../auth/auth.service'; +import { RsvpService } from '../rsvp/rsvp.service'; + +/** + * Guard that requires a valid JWT AND Super Admin Perms in Airtable. + * Checks Airtable on every request — no caching, so revocations are instant. + */ +@Injectable() +export class SuperAdminGuard implements CanActivate { + constructor( + private readonly authService: AuthService, + private readonly rsvpService: RsvpService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const authHeader = request.headers.authorization; + + if (!authHeader?.startsWith('Bearer ')) { + throw new UnauthorizedException(); + } + + let user: Record; + try { + const token = authHeader.split(' ')[1]; + user = this.authService.verifyToken(token); + } catch { + throw new UnauthorizedException(); + } + + const email = user?.email as string; + if (!email) throw new ForbiddenException(); + + const perms = await this.rsvpService.getPerms(email); + if (perms !== 'Super Admin') { + throw new ForbiddenException(); + } + + request.user = user; + return true; + } +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts new file mode 100644 index 0000000..1e0b512 --- /dev/null +++ b/backend/src/app.module.ts @@ -0,0 +1,68 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { ScheduleModule } from '@nestjs/schedule'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { RsvpModule } from './rsvp/rsvp.module'; +import { AuthModule } from './auth/auth.module'; +import { HackatimeModule } from './hackatime/hackatime.module'; +import { OnboardingModule } from './onboarding/onboarding.module'; +import { ProjectsModule } from './projects/projects.module'; +import { AuditLogModule } from './audit-log/audit-log.module'; +import { AdminModule } from './admin/admin.module'; +import { NewsModule } from './news/news.module'; +import { ShopModule } from './shop/shop.module'; +import { DevlogsModule } from './devlogs/devlogs.module'; +import { FraudReviewModule } from './fraud-review/fraud-review.module'; +import { LapseModule } from './lapse/lapse.module'; +import { HcbModule } from './hcb/hcb.module'; +import { User } from './entities/user.entity'; +import { Session } from './entities/session.entity'; +import { Project } from './entities/project.entity'; +import { AuditLog } from './entities/audit-log.entity'; +import { NewsItem } from './entities/news-item.entity'; +import { ProjectReview } from './entities/project-review.entity'; +import { Comment } from './entities/comment.entity'; +import { ShopItem } from './entities/shop-item.entity'; +import { Order } from './entities/order.entity'; +import { FulfillmentUpdate } from './entities/fulfillment-update.entity'; +import { Submission } from './entities/submission.entity'; +import { ShopSuggestion } from './entities/shop-suggestion.entity'; +import { ShopSuggestionVote } from './entities/shop-suggestion-vote.entity'; +import { Devlog } from './entities/devlog.entity'; +import { FraudReview } from './entities/fraud-review.entity'; +import { HcbCredential } from './entities/hcb-credential.entity'; +import { HealthController } from './health.controller'; + +@Module({ + controllers: [HealthController], + imports: [ + ConfigModule.forRoot({ isGlobal: true }), + ScheduleModule.forRoot(), + TypeOrmModule.forRootAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + type: 'postgres', + url: config.getOrThrow('DATABASE_URL'), + entities: [User, Session, Project, AuditLog, NewsItem, ProjectReview, Comment, ShopItem, Order, FulfillmentUpdate, Submission, ShopSuggestion, ShopSuggestionVote, Devlog, FraudReview, HcbCredential], + migrations: [__dirname + '/migrations/*{.ts,.js}'], + migrationsRun: true, + synchronize: false, + }), + }), + RsvpModule, + AuthModule, + HackatimeModule, + OnboardingModule, + ProjectsModule, + AuditLogModule, + AdminModule, + NewsModule, + ShopModule, + DevlogsModule, + FraudReviewModule, + LapseModule, + HcbModule, + ], +}) +export class AppModule {} diff --git a/backend/src/audit-log/audit-log.controller.ts b/backend/src/audit-log/audit-log.controller.ts new file mode 100644 index 0000000..e813623 --- /dev/null +++ b/backend/src/audit-log/audit-log.controller.ts @@ -0,0 +1,26 @@ +import { + Controller, + Get, + Req, + UseGuards, + UnauthorizedException, +} from '@nestjs/common'; +import { Throttle } from '@nestjs/throttler'; +import type { Request } from 'express'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { AuditLogService } from './audit-log.service'; + +@Controller('api/audit-log') +export class AuditLogController { + constructor(private readonly auditLogService: AuditLogService) {} + + @Throttle({ default: { limit: 30, ttl: 60000 } }) + @UseGuards(JwtAuthGuard) + @Get() + async list(@Req() req: Request) { + const userId = (req as any).user?.uid; + if (!userId) throw new UnauthorizedException('No user identity'); + + return this.auditLogService.getForUser(userId); + } +} diff --git a/backend/src/audit-log/audit-log.module.ts b/backend/src/audit-log/audit-log.module.ts new file mode 100644 index 0000000..84e6da4 --- /dev/null +++ b/backend/src/audit-log/audit-log.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthModule } from '../auth/auth.module'; +import { AuditLog } from '../entities/audit-log.entity'; +import { AuditLogController } from './audit-log.controller'; +import { AuditLogService } from './audit-log.service'; + +@Module({ + imports: [AuthModule, TypeOrmModule.forFeature([AuditLog])], + controllers: [AuditLogController], + providers: [AuditLogService], + exports: [AuditLogService], +}) +export class AuditLogModule {} diff --git a/backend/src/audit-log/audit-log.service.ts b/backend/src/audit-log/audit-log.service.ts new file mode 100644 index 0000000..2fdcb9d --- /dev/null +++ b/backend/src/audit-log/audit-log.service.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AuditLog, AuditAction } from '../entities/audit-log.entity'; + +@Injectable() +export class AuditLogService { + constructor( + @InjectRepository(AuditLog) + private auditLogRepo: Repository, + ) {} + + async log(userId: string, action: AuditAction, label: string, impersonatorName?: string): Promise { + const prefix = impersonatorName ? `[${impersonatorName} performed an action on your behalf] ` : ''; + const entry = this.auditLogRepo.create({ + userId, + action, + label: (prefix + label).replace(/[<>"'`&\\]/g, '').replace(/\0/g, '').trim().slice(0, 255), + }); + await this.auditLogRepo.save(entry); + } + + async getForUser(userId: string, limit = 50) { + return this.auditLogRepo.find({ + where: { userId }, + order: { createdAt: 'DESC' }, + take: limit, + select: ['id', 'action', 'label', 'createdAt'], + }); + } + + /** + * Returns distinct user IDs with at least one `hackatime_ownership_failed` + * audit log whose label contains any of `labelSubstrings`. Used by the + * daily Hackatime-recovery cron to find candidates whose linked account + * was originally flagged as banned/red-trust. + */ + async findUsersWithOwnershipFailLabels(labelSubstrings: string[]): Promise { + if (labelSubstrings.length === 0) return []; + const qb = this.auditLogRepo + .createQueryBuilder('a') + .select('DISTINCT a.user_id', 'user_id') + .where("a.action = 'hackatime_ownership_failed'"); + qb.andWhere( + `(${labelSubstrings.map((_, i) => `a.label LIKE :s${i}`).join(' OR ')})`, + Object.fromEntries(labelSubstrings.map((s, i) => [`s${i}`, `%${s}%`])), + ); + const rows = await qb.getRawMany<{ user_id: string }>(); + return rows.map((r) => r.user_id); + } +} diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts new file mode 100644 index 0000000..b7c2f06 --- /dev/null +++ b/backend/src/auth/auth.controller.ts @@ -0,0 +1,228 @@ +import { + Controller, + Get, + Post, + Patch, + Body, + Req, + Query, + UseGuards, + UnauthorizedException, + BadRequestException, + ForbiddenException, +} from '@nestjs/common'; +import { Throttle } from '@nestjs/throttler'; +import type { Request } from 'express'; +import { AuthService, ALLOWED_GENDERS, type Gender } from './auth.service'; +import { JwtAuthGuard } from './jwt-auth.guard'; +import { RsvpService } from '../rsvp/rsvp.service'; +import { IdentityService } from '../identity/identity.service'; + +@Controller('api/auth') +export class AuthController { + constructor( + private readonly authService: AuthService, + private readonly rsvpService: RsvpService, + private readonly identityService: IdentityService, + ) {} + + @Throttle({ default: { limit: 10, ttl: 60000 } }) + @Post('start') + start(@Body() body: { email?: string }) { + return this.authService.startAuth(body.email); + } + + @Throttle({ default: { limit: 10, ttl: 60000 } }) + @Post('handle-callback') + async handleCallback( + @Body() + body: { + code: string; + state: string; + storedState: string; + attribution?: { + utm_source?: string | null; + utm_medium?: string | null; + utm_campaign?: string | null; + referrer?: string | null; + landing_path?: string | null; + }; + }, + ) { + if (!body.code) { + throw new BadRequestException('Authorization code is required'); + } + if (!body.state || !body.storedState) { + throw new BadRequestException('State parameters are required'); + } + try { + return await this.authService.handleCallback( + body.code, + body.state, + body.storedState, + body.attribution, + ); + } catch { + throw new UnauthorizedException('Authentication failed'); + } + } + + /** + * Exchange a refresh token for a new JWT + rotated refresh token. + */ + @Throttle({ default: { limit: 10, ttl: 60000 } }) + @Post('refresh') + async refresh(@Body() body: { refreshToken: string }) { + if (!body.refreshToken) { + throw new BadRequestException('Refresh token is required'); + } + try { + return await this.authService.refreshAuth(body.refreshToken); + } catch { + throw new UnauthorizedException('Invalid refresh token'); + } + } + + @UseGuards(JwtAuthGuard) + @Get('me') + async me(@Req() req: Request) { + const user = (req as any).user; + // Check if user has been banned since the JWT was issued + try { + const perms = await this.rsvpService.getPerms(user.email); + if (perms === 'Banned') { + throw new UnauthorizedException('Account banned'); + } + } catch (err) { + if (err instanceof UnauthorizedException) throw err; + // Airtable lookup failed — don't block the response + } + // Include impersonation context if present so the frontend can show it + const result: Record = { ...user }; + if (user.impersonator_uid) { + result.impersonator_uid = user.impersonator_uid; + result.impersonator_name = user.impersonator_name; + } + return result; + } + + @UseGuards(JwtAuthGuard) + @Get('shipping-eligibility') + async shippingEligibility(@Req() req: Request) { + const user = (req as any).user; + const hasAddress = !!user.has_address; + const hasBirthdate = !!user.has_birthdate; + const identityVerified = await this.identityService.isVerified({ + slackId: user.slack_id, + email: user.email, + }); + return { + hasAddress, + hasBirthdate, + identityVerified, + eligible: hasAddress && hasBirthdate && identityVerified, + addressPortalUrl: 'https://auth.hackclub.com/portal/address', + identityPortalUrl: 'https://auth.hackclub.com/verifications/document', + }; + } + + @UseGuards(JwtAuthGuard) + @Post('rsvp') + async rsvpFromSession(@Req() req: Request) { + const email = (req as any).user?.email; + if (!email) { + throw new BadRequestException('No email in token'); + } + return this.rsvpService.createRsvp(email); + } + + @UseGuards(JwtAuthGuard) + @Get('scope') + async checkScope( + @Req() req: Request, + @Query('scope') scope: string, + ) { + const email = (req as any).user?.email; + if (!email) throw new ForbiddenException(); + + const perms = await this.rsvpService.getPerms(email); + + const scopeRequirements: Record = { + admin: ['Super Admin'], + reviewer: ['Super Admin', 'Reviewer', 'Fraud Reviewer'], + audit: ['Super Admin', 'Fraud Reviewer'], + }; + + const allowed = scopeRequirements[scope]; + if (!allowed || !perms || !allowed.includes(perms)) { + throw new ForbiddenException(); + } + + return { allowed: true, perms }; + } + + @Throttle({ default: { limit: 10, ttl: 60000 } }) + @UseGuards(JwtAuthGuard) + @Patch('nickname') + async updateNickname( + @Req() req: Request, + @Body() body: { nickname?: string }, + ) { + const uid = (req as any).user?.uid; + if (!uid) throw new UnauthorizedException(); + const nickname = (body.nickname ?? '').trim(); + if (!nickname || nickname.length > 50) { + throw new BadRequestException('Nickname must be 1–50 characters'); + } + return this.authService.updateNickname(uid, nickname); + } + + @Throttle({ default: { limit: 10, ttl: 60000 } }) + @UseGuards(JwtAuthGuard) + @Patch('gender') + async updateGender( + @Req() req: Request, + @Body() body: { gender?: string }, + ) { + const uid = (req as any).user?.uid; + if (!uid) throw new UnauthorizedException(); + const gender = body.gender; + if (!gender || !ALLOWED_GENDERS.includes(gender as Gender)) { + throw new BadRequestException('Invalid gender value'); + } + return this.authService.updateGender(uid, gender as Gender); + } + + @UseGuards(JwtAuthGuard) + @Get('intent') + async getIntent(@Req() req: Request) { + const uid = (req as any).user?.uid; + if (!uid) throw new UnauthorizedException(); + return this.authService.getIntentStatus(uid); + } + + @Throttle({ default: { limit: 10, ttl: 60000 } }) + @UseGuards(JwtAuthGuard) + @Post('intent') + async setIntent(@Req() req: Request, @Body() body: { choice?: string }) { + const uid = (req as any).user?.uid; + if (!uid) throw new UnauthorizedException(); + const choice = body.choice; + const allowed = ['Hackathon', 'Shop', 'Browsing', 'Both']; + if (!choice || !allowed.includes(choice)) { + throw new BadRequestException(`choice must be one of: ${allowed.join(', ')}`); + } + return this.authService.setIntent(uid, choice); + } + + /** + * Invalidates the session's refresh token. The proxy clears cookies. + */ + @Post('logout') + async logout(@Body() body: { refreshToken?: string }) { + if (body.refreshToken) { + await this.authService.invalidateSession(body.refreshToken); + } + return { success: true }; + } +} diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts new file mode 100644 index 0000000..13474f9 --- /dev/null +++ b/backend/src/auth/auth.module.ts @@ -0,0 +1,48 @@ +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; +import { APP_GUARD } from '@nestjs/core'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { RsvpModule } from '../rsvp/rsvp.module'; +import { IdentityModule } from '../identity/identity.module'; +import { User } from '../entities/user.entity'; +import { Session } from '../entities/session.entity'; +import { AuthService } from './auth.service'; +import { AuthController } from './auth.controller'; +import { JwtAuthGuard } from './jwt-auth.guard'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([User, Session]), + JwtModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (configService: ConfigService) => ({ + secret: configService.getOrThrow('JWT_SECRET'), + signOptions: { + expiresIn: '1h', + issuer: 'beest', + audience: 'beest', + }, + verifyOptions: { + issuer: 'beest', + audience: 'beest', + }, + }), + }), + ThrottlerModule.forRoot({ + throttlers: [{ ttl: 60000, limit: 30 }], + }), + RsvpModule, + IdentityModule, + ], + controllers: [AuthController], + providers: [ + AuthService, + JwtAuthGuard, + { provide: APP_GUARD, useClass: ThrottlerGuard }, + ], + exports: [AuthService, JwtAuthGuard], +}) +export class AuthModule {} diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts new file mode 100644 index 0000000..4e39933 --- /dev/null +++ b/backend/src/auth/auth.service.ts @@ -0,0 +1,489 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { createHash, createHmac, randomBytes, timingSafeEqual } from 'crypto'; +import { fetchWithTimeout } from '../fetch.util'; +import { RsvpService } from '../rsvp/rsvp.service'; +import { User } from '../entities/user.entity'; +import { Session } from '../entities/session.entity'; + +const ALLOWED_REDIRECTS = new Set(['/home', '/tutorial']); + +const EMAIL_RE = + /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; + +const REFRESH_TOKEN_EXPIRY_MS = 90 * 24 * 60 * 60 * 1000; // 90 days + +export const ALLOWED_GENDERS = [ + 'male', + 'female', + 'non_binary_other', + 'not_sure', + 'prefer_not_to_say', +] as const; +export type Gender = (typeof ALLOWED_GENDERS)[number]; + +@Injectable() +export class AuthService { + private readonly logger = new Logger(AuthService.name); + private readonly clientId: string; + private readonly clientSecret: string; + private readonly redirectUri: string; + private readonly jwtSecret: string; + + private readonly authorizeUrl = + 'https://auth.hackclub.com/oauth/authorize'; + private readonly tokenUrl = 'https://auth.hackclub.com/oauth/token'; + private readonly userinfoUrl = 'https://auth.hackclub.com/oauth/userinfo'; + + private readonly scopes = [ + 'openid', + 'email', + 'name', + 'profile', + 'birthdate', + 'address', + 'verification_status', + 'slack_id', + 'basic_info', + ].join(' '); + + constructor( + private configService: ConfigService, + private jwtService: JwtService, + private rsvpService: RsvpService, + @InjectRepository(User) + private userRepo: Repository, + @InjectRepository(Session) + private sessionRepo: Repository, + ) { + this.clientId = this.configService.getOrThrow('CLIENT_ID'); + this.clientSecret = this.configService.getOrThrow('CLIENT_SECRET'); + this.redirectUri = this.configService.get( + 'REDIRECT_URI', + 'http://localhost:5173/oauth/callback', + ); + this.jwtSecret = this.configService.getOrThrow('JWT_SECRET'); + } + + private signState(state: string): string { + return createHmac('sha256', this.jwtSecret) + .update(`hca:${state}`) + .digest('hex'); + } + + startAuth(email?: string): { url: string; state: string } { + const state = crypto.randomUUID(); + const signature = this.signState(state); + const signedState = `${state}.${signature}`; + + const sanitizedEmail = + email && EMAIL_RE.test(email.trim()) ? email.trim() : undefined; + + const params = new URLSearchParams({ + client_id: this.clientId, + redirect_uri: this.redirectUri, + response_type: 'code', + scope: this.scopes, + state: signedState, + return_to: '/join/beest', + }); + + if (sanitizedEmail) { + params.set('login_hint', sanitizedEmail); + } + + return { + url: `${this.authorizeUrl}?${params.toString()}`, + state, + }; + } + + async handleCallback( + code: string, + returnedSignedState: string, + cookieState: string, + attribution?: { + utm_source?: string | null; + utm_medium?: string | null; + utm_campaign?: string | null; + referrer?: string | null; + landing_path?: string | null; + }, + ): Promise<{ token: string; refreshToken: string; redirectTo: string }> { + // 1. Verify state + const dotIndex = returnedSignedState.lastIndexOf('.'); + if (dotIndex === -1) { + throw new Error('Malformed state parameter'); + } + + const stateValue = returnedSignedState.substring(0, dotIndex); + const signature = returnedSignedState.substring(dotIndex + 1); + + const stateBuffer = Buffer.from(stateValue); + const cookieBuffer = Buffer.from(cookieState); + if ( + stateBuffer.length !== cookieBuffer.length || + !timingSafeEqual(stateBuffer, cookieBuffer) + ) { + throw new Error('State mismatch'); + } + + const expectedSignature = this.signState(stateValue); + const sigBuffer = Buffer.from(signature); + const expectedBuffer = Buffer.from(expectedSignature); + if ( + sigBuffer.length !== expectedBuffer.length || + !timingSafeEqual(sigBuffer, expectedBuffer) + ) { + throw new Error('Invalid state signature'); + } + + // 2. Exchange code for tokens + const tokenResponse = await fetchWithTimeout(this.tokenUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: this.redirectUri, + client_id: this.clientId, + client_secret: this.clientSecret, + }), + }); + + if (!tokenResponse.ok) { + this.logger.error(`Token exchange failed: ${tokenResponse.status}`); + throw new Error('Token exchange failed'); + } + + const tokens = await tokenResponse.json().catch(() => null); + if (!tokens?.access_token) { + throw new Error('Invalid token response'); + } + + // 3. Fetch user info + const userinfoResponse = await fetchWithTimeout(this.userinfoUrl, { + headers: { Authorization: `Bearer ${tokens.access_token}` }, + }); + + if (!userinfoResponse.ok) { + this.logger.error('Failed to fetch user info'); + throw new Error('Failed to fetch user info'); + } + + const userinfo = await userinfoResponse.json().catch(() => null); + if (!userinfo?.sub) { + throw new Error('Invalid userinfo response'); + } + + // 4. Upsert user in DB (pass HCA tokens so they're persisted encrypted) + const user = await this.upsertUser( + userinfo, + tokens.access_token, + tokens.refresh_token, + attribution, + ); + + // 4b. Check if user is banned + try { + const perms = await this.rsvpService.getPerms(userinfo.email); + if (perms === 'Banned') { + return { + token: '', + refreshToken: '', + redirectTo: 'https://fraud.hackclub.com/', + }; + } + } catch (err) { + this.logger.error(`Perms check failed for ${userinfo.sub}: ${err}`); + } + + // 5. Submit RSVP + let redirectTo = '/home'; + try { + const rsvpResult = await this.rsvpService.createRsvp(userinfo.email); + redirectTo = rsvpResult.existing ? '/home' : '/tutorial'; + } catch (err) { + this.logger.error( + `RSVP submission failed for user ${userinfo.sub}: ${err}`, + ); + } + + if (!ALLOWED_REDIRECTS.has(redirectTo)) { + redirectTo = '/home'; + } + + // 6. Create session with refresh token + const refreshToken = await this.createSession(user.id); + + // 7. Sign JWT — no PII beyond what's needed for display + auth checks + const token = this.jwtService.sign({ + sub: userinfo.sub, + uid: user.id, + email: userinfo.email, + name: userinfo.name, + nickname: userinfo.nickname, + slack_id: userinfo.slack_id, + has_address: user.hasAddress, + has_birthdate: user.hasBirthdate, + gender: user.gender, + }); + + return { token, refreshToken, redirectTo }; + } + + /** + * Validates a refresh token, rotates it, and issues a new JWT. + */ + async refreshAuth( + refreshToken: string, + ): Promise<{ token: string; refreshToken: string }> { + const hash = createHash('sha256').update(refreshToken).digest('hex'); + const session = await this.sessionRepo.findOne({ + where: { refreshTokenHash: hash }, + relations: ['user'], + }); + + if (!session || session.expiresAt < new Date()) { + if (session) await this.sessionRepo.remove(session); + throw new Error('Invalid or expired refresh token'); + } + + const user = session.user; + + // Rotate: delete old session, create new one + await this.sessionRepo.remove(session); + const newRefreshToken = await this.createSession(user.id); + + const token = this.jwtService.sign({ + sub: user.hcaSub, + uid: user.id, + email: user.email, + name: user.name, + nickname: user.nickname, + slack_id: user.slackId, + has_address: user.hasAddress, + has_birthdate: user.hasBirthdate, + gender: user.gender, + }); + + return { token, refreshToken: newRefreshToken }; + } + + /** + * Invalidates a refresh token (logout). + */ + async invalidateSession(refreshToken: string): Promise { + const hash = createHash('sha256').update(refreshToken).digest('hex'); + await this.sessionRepo.delete({ refreshTokenHash: hash }); + } + + verifyToken(token: string): Record { + return this.jwtService.verify(token); + } + + /** + * Issues a JWT that lets an admin act as the target user. + * The token carries the target user's identity but includes + * impersonator_uid / impersonator_name so audit logs can attribute actions. + */ + async issueImpersonationToken( + targetUserId: string, + adminUid: string, + adminName: string, + ): Promise<{ token: string }> { + const user = await this.userRepo.findOne({ where: { id: targetUserId } }); + if (!user) throw new Error('User not found'); + + const token = this.jwtService.sign({ + sub: user.hcaSub, + uid: user.id, + email: user.email, + name: user.name, + nickname: user.nickname, + slack_id: user.slackId, + has_address: user.hasAddress, + has_birthdate: user.hasBirthdate, + gender: user.gender, + impersonator_uid: adminUid, + impersonator_name: adminName, + }); + + return { token }; + } + + async updateNickname( + userId: string, + nickname: string, + ): Promise<{ token: string }> { + const user = await this.userRepo.findOne({ where: { id: userId } }); + if (!user) throw new Error('User not found'); + + user.nickname = nickname; + await this.userRepo.save(user); + + const token = this.jwtService.sign({ + sub: user.hcaSub, + uid: user.id, + email: user.email, + name: user.name, + nickname: user.nickname, + slack_id: user.slackId, + has_address: user.hasAddress, + has_birthdate: user.hasBirthdate, + gender: user.gender, + }); + + return { token }; + } + + async updateGender( + userId: string, + gender: Gender, + ): Promise<{ token: string }> { + const user = await this.userRepo.findOne({ where: { id: userId } }); + if (!user) throw new Error('User not found'); + + user.gender = gender; + await this.userRepo.save(user); + + const token = this.jwtService.sign({ + sub: user.hcaSub, + uid: user.id, + email: user.email, + name: user.name, + nickname: user.nickname, + slack_id: user.slackId, + has_address: user.hasAddress, + has_birthdate: user.hasBirthdate, + gender: user.gender, + }); + + return { token }; + } + + /** Whether the one-time home "hackathon or shop?" prompt still needs answering. */ + async getIntentStatus(userId: string): Promise<{ needsPrompt: boolean }> { + const user = await this.userRepo.findOne({ + where: { id: userId }, + select: ['id', 'intent'], + }); + return { needsPrompt: !!user && !user.intent }; + } + + /** + * Records the user's home-prompt answer. Writes Airtable FIRST — only on + * success do we set the local flag, so a transient Airtable failure leaves + * the prompt showing (it keeps showing until answered) rather than silently + * dropping the response. + */ + async setIntent(userId: string, intent: string): Promise<{ success: true }> { + const user = await this.userRepo.findOne({ where: { id: userId } }); + if (!user) throw new Error('User not found'); + + await this.rsvpService.setIntent(user.email, intent); + + user.intent = intent; + await this.userRepo.save(user); + return { success: true }; + } + + private async upsertUser( + userinfo: Record, + hcaAccessToken?: string, + hcaRefreshToken?: string, + attribution?: { + utm_source?: string | null; + utm_medium?: string | null; + utm_campaign?: string | null; + referrer?: string | null; + landing_path?: string | null; + }, + ): Promise { + const hasAddress = !!( + userinfo.address || + (Array.isArray(userinfo.addresses) && userinfo.addresses.length > 0) + ); + const hasBirthdate = !!( + userinfo.birthdate && userinfo.birthdate.trim() !== '' + ); + + const trim = (v: string | null | undefined): string | undefined => + typeof v === 'string' && v.trim() !== '' + ? v.trim().slice(0, 255) + : undefined; + + let user = await this.userRepo.findOne({ + where: { hcaSub: userinfo.sub }, + }); + + if (user) { + user.email = userinfo.email; + user.name = userinfo.name; + user.nickname = userinfo.nickname; + user.slackId = userinfo.slack_id; + user.hasAddress = hasAddress; + user.hasBirthdate = hasBirthdate; + if (hcaAccessToken) user.hcaAccessToken = hcaAccessToken; + if (hcaRefreshToken) user.hcaRefreshToken = hcaRefreshToken; + return this.userRepo.save(user); + } + + // New user — attempt insert. If a concurrent request already inserted + // this hca_sub, catch the unique constraint violation and update instead. + try { + user = this.userRepo.create({ + hcaSub: userinfo.sub, + email: userinfo.email, + name: userinfo.name, + nickname: userinfo.nickname, + slackId: userinfo.slack_id, + hasAddress, + hasBirthdate, + hcaAccessToken: hcaAccessToken ?? undefined, + hcaRefreshToken: hcaRefreshToken ?? undefined, + utmSource: trim(attribution?.utm_source), + utmMedium: trim(attribution?.utm_medium), + utmCampaign: trim(attribution?.utm_campaign), + referrer: trim(attribution?.referrer), + landingPath: trim(attribution?.landing_path), + }); + return await this.userRepo.save(user); + } catch (err: any) { + if (err?.code === '23505') { + // Unique violation — the other request won the insert race + user = await this.userRepo.findOne({ + where: { hcaSub: userinfo.sub }, + }); + if (!user) throw err; // shouldn't happen, but safety net + user.email = userinfo.email; + user.name = userinfo.name; + user.nickname = userinfo.nickname; + user.slackId = userinfo.slack_id; + user.hasAddress = hasAddress; + user.hasBirthdate = hasBirthdate; + if (hcaAccessToken) user.hcaAccessToken = hcaAccessToken; + if (hcaRefreshToken) user.hcaRefreshToken = hcaRefreshToken; + return this.userRepo.save(user); + } + throw err; + } + } + + private async createSession(userId: string): Promise { + const refreshToken = randomBytes(48).toString('base64url'); + const hash = createHash('sha256').update(refreshToken).digest('hex'); + + const session = this.sessionRepo.create({ + userId, + refreshTokenHash: hash, + expiresAt: new Date(Date.now() + REFRESH_TOKEN_EXPIRY_MS), + }); + await this.sessionRepo.save(session); + + return refreshToken; + } +} diff --git a/backend/src/auth/jwt-auth.guard.ts b/backend/src/auth/jwt-auth.guard.ts new file mode 100644 index 0000000..1c12c1b --- /dev/null +++ b/backend/src/auth/jwt-auth.guard.ts @@ -0,0 +1,29 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { AuthService } from './auth.service'; + +@Injectable() +export class JwtAuthGuard implements CanActivate { + constructor(private authService: AuthService) {} + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + const authHeader = request.headers.authorization; + + if (!authHeader?.startsWith('Bearer ')) { + throw new UnauthorizedException(); + } + + try { + const token = authHeader.split(' ')[1]; + request.user = this.authService.verifyToken(token); + return true; + } catch { + throw new UnauthorizedException(); + } + } +} diff --git a/backend/src/crypto.util.ts b/backend/src/crypto.util.ts new file mode 100644 index 0000000..ada776b --- /dev/null +++ b/backend/src/crypto.util.ts @@ -0,0 +1,58 @@ +import { + createCipheriv, + createDecipheriv, + randomBytes, +} from 'crypto'; +import type { ValueTransformer } from 'typeorm'; + +const ALGO = 'aes-256-gcm'; +const IV_LENGTH = 12; + +export function encrypt(plaintext: string, keyHex: string): string { + const key = Buffer.from(keyHex, 'hex'); + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGO, key, iv); + + const encrypted = Buffer.concat([ + cipher.update(plaintext, 'utf8'), + cipher.final(), + ]); + const tag = cipher.getAuthTag(); + + // iv.tag.ciphertext — all base64url for safe storage + return `${iv.toString('base64')}.${tag.toString('base64')}.${encrypted.toString('base64')}`; +} + +export function decrypt(encoded: string, keyHex: string): string { + const key = Buffer.from(keyHex, 'hex'); + const parts = encoded.split('.'); + if (parts.length !== 3) throw new Error('Malformed encrypted value'); + + const iv = Buffer.from(parts[0], 'base64'); + const tag = Buffer.from(parts[1], 'base64'); + const encrypted = Buffer.from(parts[2], 'base64'); + + const decipher = createDecipheriv(ALGO, key, iv); + decipher.setAuthTag(tag); + + return decipher.update(encrypted).toString('utf8') + decipher.final('utf8'); +} + +/** + * TypeORM column transformer that encrypts on write and decrypts on read. + * Reads DB_ENCRYPTION_KEY from process.env (available after ConfigModule.forRoot). + */ +export const encryptedTransformer: ValueTransformer = { + to(value: string | null): string | null { + if (!value) return value; + const key = process.env.DB_ENCRYPTION_KEY; + if (!key) throw new Error('DB_ENCRYPTION_KEY is not set'); + return encrypt(value, key); + }, + from(value: string | null): string | null { + if (!value) return value; + const key = process.env.DB_ENCRYPTION_KEY; + if (!key) throw new Error('DB_ENCRYPTION_KEY is not set'); + return decrypt(value, key); + }, +}; diff --git a/backend/src/data-source.ts b/backend/src/data-source.ts new file mode 100644 index 0000000..d42ee02 --- /dev/null +++ b/backend/src/data-source.ts @@ -0,0 +1,13 @@ +import 'dotenv/config'; +import { DataSource } from 'typeorm'; + +/** + * Standalone DataSource used by the TypeORM CLI for migrations. + * Reads DATABASE_URL from the environment (loaded via dotenv above). + */ +export default new DataSource({ + type: 'postgres', + url: process.env.DATABASE_URL, + entities: ['src/entities/*.ts'], + migrations: ['src/migrations/*.ts'], +}); diff --git a/backend/src/devlogs/create-devlog.dto.ts b/backend/src/devlogs/create-devlog.dto.ts new file mode 100644 index 0000000..8736a57 --- /dev/null +++ b/backend/src/devlogs/create-devlog.dto.ts @@ -0,0 +1,8 @@ +export class CreateDevlogDto { + title: string; + text: string; + /** Required: every devlog must be attached to one of the user's projects. */ + projectId: string; + /** Base64 data URIs (data:image/png;base64,...). Max 4 images. */ + images?: string[]; +} diff --git a/backend/src/devlogs/devlogs.controller.ts b/backend/src/devlogs/devlogs.controller.ts new file mode 100644 index 0000000..0d08d8e --- /dev/null +++ b/backend/src/devlogs/devlogs.controller.ts @@ -0,0 +1,51 @@ +import { + Controller, + Get, + Post, + Delete, + Param, + Body, + Req, + UseGuards, + UnauthorizedException, +} from '@nestjs/common'; +import { Throttle } from '@nestjs/throttler'; +import type { Request } from 'express'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { DevlogsService } from './devlogs.service'; +import { CreateDevlogDto } from './create-devlog.dto'; + +@Controller('api/devlogs') +export class DevlogsController { + constructor(private readonly devlogsService: DevlogsService) {} + + /** List the current user's devlogs, newest first. */ + @Throttle({ default: { limit: 30, ttl: 60000 } }) + @UseGuards(JwtAuthGuard) + @Get() + async list(@Req() req: Request) { + const user = (req as any).user; + if (!user?.uid) throw new UnauthorizedException('No user identity'); + return this.devlogsService.findByUser(user.uid); + } + + /** Create a new devlog. Optional image upload via base64 data URIs. */ + @Throttle({ default: { limit: 10, ttl: 60000 } }) + @UseGuards(JwtAuthGuard) + @Post() + async create(@Req() req: Request, @Body() dto: CreateDevlogDto) { + const user = (req as any).user; + if (!user?.uid) throw new UnauthorizedException('No user identity'); + return this.devlogsService.create(user.uid, dto); + } + + /** Delete one of the current user's devlogs. */ + @Throttle({ default: { limit: 10, ttl: 60000 } }) + @UseGuards(JwtAuthGuard) + @Delete(':id') + async remove(@Param('id') id: string, @Req() req: Request) { + const user = (req as any).user; + if (!user?.uid) throw new UnauthorizedException('No user identity'); + return this.devlogsService.deleteOwn(user.uid, id); + } +} diff --git a/backend/src/devlogs/devlogs.module.ts b/backend/src/devlogs/devlogs.module.ts new file mode 100644 index 0000000..0652318 --- /dev/null +++ b/backend/src/devlogs/devlogs.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthModule } from '../auth/auth.module'; +import { AuditLogModule } from '../audit-log/audit-log.module'; +import { Devlog } from '../entities/devlog.entity'; +import { Project } from '../entities/project.entity'; +import { DevlogsController } from './devlogs.controller'; +import { DevlogsService } from './devlogs.service'; + +@Module({ + imports: [ + AuthModule, + AuditLogModule, + TypeOrmModule.forFeature([Devlog, Project]), + ], + controllers: [DevlogsController], + providers: [DevlogsService], + exports: [DevlogsService], +}) +export class DevlogsModule {} diff --git a/backend/src/devlogs/devlogs.service.ts b/backend/src/devlogs/devlogs.service.ts new file mode 100644 index 0000000..29a03da --- /dev/null +++ b/backend/src/devlogs/devlogs.service.ts @@ -0,0 +1,295 @@ +import { + Injectable, + BadRequestException, + ForbiddenException, + NotFoundException, + Logger, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Devlog } from '../entities/devlog.entity'; +import { Project } from '../entities/project.entity'; +import { fetchWithTimeout } from '../fetch.util'; +import { AuditLogService } from '../audit-log/audit-log.service'; +import { CreateDevlogDto } from './create-devlog.dto'; + +const CDN_UPLOAD_URL = 'https://cdn.hackclub.com/api/v4/upload'; + +const MIME_EXTENSIONS: Record = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/gif': 'gif', + 'image/webp': 'webp', +}; + +const IMAGE_SIGNATURES: { mime: string; b64Prefix: string }[] = [ + { mime: 'image/png', b64Prefix: 'iVBOR' }, + { mime: 'image/jpeg', b64Prefix: '/9j/' }, + { mime: 'image/gif', b64Prefix: 'R0lGOD' }, + { mime: 'image/webp', b64Prefix: 'UklGR' }, +]; + +const MAX_IMAGES_PER_DEVLOG = 4; +const MAX_TITLE_LEN = 120; +const MAX_TEXT_LEN = 5000; + +@Injectable() +export class DevlogsService { + private readonly logger = new Logger(DevlogsService.name); + private readonly cdnApiKey: string; + + constructor( + private configService: ConfigService, + private auditLogService: AuditLogService, + @InjectRepository(Devlog) private devlogRepo: Repository, + @InjectRepository(Project) private projectRepo: Repository, + ) { + this.cdnApiKey = this.configService.getOrThrow('CDN_API_KEY'); + } + + /* ---------------------------------------------------------------- */ + /* Public API */ + /* ---------------------------------------------------------------- */ + + async create(userId: string, dto: CreateDevlogDto) { + const title = this.requireTitle(dto.title); + const text = this.requireText(dto.text); + const projectId = await this.requireProjectId(dto.projectId, userId); + + const validated = this.validateImages(dto.images); + const imageUrls = await this.uploadImages(validated); + + const devlog = this.devlogRepo.create({ + userId, + projectId, + title, + text, + imageUrls, + }); + const saved = await this.devlogRepo.save(devlog); + + await this.auditLogService.log( + userId, + 'devlog_created', + `Created devlog "${title}" (${text.length} chars) on project ${projectId}`, + ); + + return this.toPublic(saved); + } + + async findByUser(userId: string) { + const rows = await this.devlogRepo.find({ + where: { userId }, + order: { createdAt: 'DESC' }, + }); + return rows.map((d) => this.toPublic(d)); + } + + async findByProject(projectId: string) { + const rows = await this.devlogRepo.find({ + where: { projectId }, + order: { createdAt: 'ASC' }, + relations: ['user'], + }); + return rows.map((d) => ({ + id: d.id, + projectId: d.projectId, + userId: d.userId, + userName: d.user?.name ?? null, + title: d.title, + text: d.text, + imageUrls: d.imageUrls ?? [], + createdAt: d.createdAt, + })); + } + + async deleteOwn(userId: string, id: string) { + const devlog = await this.devlogRepo.findOne({ where: { id } }); + if (!devlog) throw new NotFoundException('Devlog not found'); + if (devlog.userId !== userId) { + throw new ForbiddenException('You can only delete your own devlogs'); + } + await this.devlogRepo.delete({ id }); + await this.auditLogService.log(userId, 'devlog_deleted', `Deleted devlog ${id}`); + return { ok: true }; + } + + /* ---------------------------------------------------------------- */ + /* Validation helpers */ + /* ---------------------------------------------------------------- */ + + /** + * Strips characters that could be used for HTML / SQL / script injection. + * Devlogs preserve newlines (unlike single-line project fields). + */ + private sanitize(raw: string): string { + return String(raw) + .replace(/[<>"'`\\]/g, '') // strip injection-relevant chars (keep & for ampersand-using prose) + .replace(/\0/g, '') // strip null bytes + .replace(/\r\n/g, '\n') + .trim(); + } + + private requireText(value: unknown): string { + if (!value || typeof value !== 'string') { + throw new BadRequestException('text is required'); + } + const clean = this.sanitize(value).slice(0, MAX_TEXT_LEN); + if (clean.length === 0) { + throw new BadRequestException('text is required'); + } + return clean; + } + + private requireTitle(value: unknown): string { + if (!value || typeof value !== 'string') { + throw new BadRequestException('title is required'); + } + // Titles are single-line — collapse any newlines that snuck in. + const oneLine = String(value).replace(/[\r\n]+/g, ' '); + const clean = this.sanitize(oneLine).slice(0, MAX_TITLE_LEN); + if (clean.length === 0) { + throw new BadRequestException('title is required'); + } + return clean; + } + + /** + * Required: returns the project id if it exists and is owned by the user. + * Throws BadRequest if missing, malformed, or not owned by the user. + */ + private async requireProjectId( + projectId: string | null | undefined, + userId: string, + ): Promise { + if (!projectId || typeof projectId !== 'string') { + throw new BadRequestException('projectId is required'); + } + const trimmed = projectId.trim(); + if (trimmed.length === 0) { + throw new BadRequestException('projectId is required'); + } + + // UUID format check + if ( + !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + trimmed, + ) + ) { + throw new BadRequestException('projectId is not a valid id'); + } + + const project = await this.projectRepo.findOne({ + where: { id: trimmed, userId }, + select: ['id'], + }); + if (!project) { + throw new BadRequestException('projectId not found or not yours'); + } + return project.id; + } + + private validateImages( + images: string[] | undefined, + ): { mime: string; buffer: Buffer }[] { + if (!images || !Array.isArray(images)) return []; + + const items = images.slice(0, MAX_IMAGES_PER_DEVLOG); + const results: { mime: string; buffer: Buffer }[] = []; + + for (let i = 0; i < items.length; i++) { + const raw = items[i]; + if (!raw || typeof raw !== 'string') continue; + + const match = raw.match( + /^data:(image\/(?:png|jpeg|gif|webp));base64,(.+)$/, + ); + if (!match) { + throw new BadRequestException( + `Image ${i + 1} must be a PNG, JPEG, GIF, or WebP image`, + ); + } + + const declaredMime = match[1]; + const b64Data = match[2]; + + const buffer = Buffer.from(b64Data, 'base64'); + + const sig = IMAGE_SIGNATURES.find((s) => s.mime === declaredMime); + if (!sig || !b64Data.startsWith(sig.b64Prefix)) { + throw new BadRequestException( + `Image ${i + 1} content does not match its declared type (${declaredMime})`, + ); + } + + // 8 MB hard cap per image + if (buffer.length > 8 * 1024 * 1024) { + throw new BadRequestException( + `Image ${i + 1} is too large (max 8 MB)`, + ); + } + + results.push({ mime: declaredMime, buffer }); + } + + return results; + } + + private async uploadImages( + items: { mime: string; buffer: Buffer }[], + ): Promise { + const urls: string[] = []; + for (let i = 0; i < items.length; i++) { + const { mime, buffer } = items[i]; + const ext = MIME_EXTENSIONS[mime] ?? 'bin'; + const filename = `devlog-${Date.now()}-${i + 1}.${ext}`; + const blob = new Blob([new Uint8Array(buffer)], { type: mime }); + const formData = new FormData(); + formData.append('file', blob, filename); + + let res: Response; + try { + res = await fetchWithTimeout(CDN_UPLOAD_URL, { + method: 'POST', + headers: { Authorization: `Bearer ${this.cdnApiKey}` }, + body: formData, + }); + } catch (err) { + this.logger.error(`CDN upload network error for image ${i + 1}: ${err}`); + throw new BadRequestException( + `Image upload failed — the CDN is unreachable. Try again without images.`, + ); + } + + if (!res.ok) { + const text = await res.text().catch(() => ''); + this.logger.error(`CDN upload failed (${res.status}): ${text}`); + throw new BadRequestException( + `Failed to upload image ${i + 1}. Please try again.`, + ); + } + + const data = await res.json().catch(() => null); + if (!data?.url) { + this.logger.error(`CDN upload returned no URL for image ${i + 1}`); + throw new BadRequestException( + `Failed to upload image ${i + 1}. Please try again.`, + ); + } + urls.push(data.url as string); + } + return urls; + } + + private toPublic(d: Devlog) { + return { + id: d.id, + projectId: d.projectId, + title: d.title, + text: d.text, + imageUrls: d.imageUrls ?? [], + createdAt: d.createdAt, + }; + } +} diff --git a/backend/src/entities/audit-log.entity.ts b/backend/src/entities/audit-log.entity.ts new file mode 100644 index 0000000..d6aae9e --- /dev/null +++ b/backend/src/entities/audit-log.entity.ts @@ -0,0 +1,58 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { User } from './user.entity'; + +export const AUDIT_ACTIONS = [ + 'project_created', + 'project_updated', + 'project_submitted', + 'project_deleted', + 'hackatime_connected', + 'hackatime_ownership_failed', + 'ban_reverted', + 'rsvp_submitted', + 'admin_ban', + 'admin_perms_change', + 'admin_pipes_adjust', + 'project_reviewed', + 'admin_impersonate', + 'admin_resync_airtable', + 'shop_purchase', + 'order_fulfilled', + 'order_refunded', + 'order_merged', + 'devlog_created', + 'devlog_deleted', + 'hcb_connected', + 'card_grant_issued', +] as const; + +export type AuditAction = (typeof AUDIT_ACTIONS)[number]; + +@Entity('audit_logs') +export class AuditLog { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'user_id' }) + userId: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: User; + + @Column({ length: 50 }) + action: string; + + @Column({ length: 255 }) + label: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; +} diff --git a/backend/src/entities/comment.entity.ts b/backend/src/entities/comment.entity.ts new file mode 100644 index 0000000..2625f73 --- /dev/null +++ b/backend/src/entities/comment.entity.ts @@ -0,0 +1,36 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Project } from './project.entity'; +import { User } from './user.entity'; + +@Entity('comments') +export class Comment { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'project_id' }) + projectId: string; + + @ManyToOne(() => Project, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'project_id' }) + project: Project; + + @Column({ name: 'user_id' }) + userId: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: User; + + @Column({ type: 'varchar', length: 500 }) + body: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; +} diff --git a/backend/src/entities/devlog.entity.ts b/backend/src/entities/devlog.entity.ts new file mode 100644 index 0000000..7287c77 --- /dev/null +++ b/backend/src/entities/devlog.entity.ts @@ -0,0 +1,67 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { User } from './user.entity'; +import { Project } from './project.entity'; + +/** + * A user's hourly devlog entry. Optionally linked to one of their projects so + * reviewers can see hour-by-hour progress on the project review screen. + */ +@Entity('devlogs') +export class Devlog { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'user_id' }) + userId: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: User; + + @Column({ name: 'project_id', nullable: true }) + projectId: string | null; + + @ManyToOne(() => Project, { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'project_id' }) + project: Project | null; + + @Column({ length: 120 }) + title: string; + + @Column({ type: 'text' }) + text: string; + + /** + * CDN URLs for any uploaded images. Stored as a JSON-serialised string + * for portability (matches the pattern used by Project.hackatimeProjectName). + */ + @Column({ + type: 'text', + name: 'image_urls', + nullable: true, + transformer: { + to: (value: string[] | null) => + value && value.length > 0 ? JSON.stringify(value) : null, + from: (value: string | null) => { + if (!value) return []; + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + }, + }, + }) + imageUrls: string[]; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; +} diff --git a/backend/src/entities/fraud-review.entity.ts b/backend/src/entities/fraud-review.entity.ts new file mode 100644 index 0000000..40d4336 --- /dev/null +++ b/backend/src/entities/fraud-review.entity.ts @@ -0,0 +1,58 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToOne, + JoinColumn, +} from 'typeorm'; +import { Project } from './project.entity'; + +/** + * Tracks a beest project's submission to the joe.fraud first-pass fraud review. + * One row per project (unique on project_id). Created when a beest reviewer + * approves a project — the project then sits in `fraud_pending` until the + * background poller observes joe.fraud's verdict. + */ +@Entity('fraud_reviews') +export class FraudReview { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'project_id', unique: true }) + projectId: string; + + @OneToOne(() => Project, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'project_id' }) + project: Project; + + // joe.fraud's project UUID. Null if the create call hasn't succeeded yet — + // the poller retries until it has one. + @Column({ type: 'varchar', name: 'remote_project_id', length: 64, nullable: true }) + remoteProjectId: string | null; + + // 'pending' | 'complete' (mirrors joe.fraud's project.status) + @Column({ length: 20, default: 'pending' }) + status: string; + + @Column({ type: 'integer', name: 'trust_score', nullable: true }) + trustScore: number | null; + + @Column({ type: 'text', nullable: true }) + justification: string | null; + + // Whether we've already POSTed the final outcome to joe.fraud after fraud + // passed. Skipped for fraud-rejected projects (their API forbids it). + @Column({ type: 'boolean', name: 'outcome_recorded', default: false }) + outcomeRecorded: boolean; + + @Column({ type: 'timestamp', name: 'reviewed_at', nullable: true }) + reviewedAt: Date | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/backend/src/entities/fulfillment-update.entity.ts b/backend/src/entities/fulfillment-update.entity.ts new file mode 100644 index 0000000..bf7ce71 --- /dev/null +++ b/backend/src/entities/fulfillment-update.entity.ts @@ -0,0 +1,39 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { User } from './user.entity'; +import { Order } from './order.entity'; + +@Entity('fulfillment_updates') +export class FulfillmentUpdate { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'user_id' }) + userId: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: User; + + @Column({ name: 'order_id' }) + orderId: string; + + @ManyToOne(() => Order, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'order_id' }) + order: Order; + + @Column({ length: 500 }) + message: string; + + @Column({ name: 'is_read', default: false }) + isRead: boolean; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; +} diff --git a/backend/src/entities/hcb-credential.entity.ts b/backend/src/entities/hcb-credential.entity.ts new file mode 100644 index 0000000..23498e0 --- /dev/null +++ b/backend/src/entities/hcb-credential.entity.ts @@ -0,0 +1,51 @@ +import { + Entity, + PrimaryColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { encryptedTransformer } from '../crypto.util'; + +/** + * Single-row store for the HCB OAuth connection used to issue card grants. + * + * There is exactly one connection for the whole app (one fixed issuing org), + * so the row is keyed by a constant id. Tokens are stored encrypted at rest + * via the AES-256-GCM column transformer — they must never be logged or sent + * to the browser. + */ +@Entity('hcb_credentials') +export class HcbCredential { + // Constant primary key — there is only ever one connection. + static readonly SINGLETON_ID = 'singleton'; + + @PrimaryColumn({ type: 'varchar', length: 32 }) + id: string; + + @Column({ name: 'access_token', type: 'text', transformer: encryptedTransformer }) + accessToken: string; + + @Column({ name: 'refresh_token', type: 'text', transformer: encryptedTransformer }) + refreshToken: string; + + // When the current access token expires (UTC). + @Column({ name: 'expires_at', type: 'timestamptz' }) + expiresAt: Date; + + @Column({ type: 'varchar', length: 255, nullable: true }) + scope: string | null; + + // Audit trail: which super admin established / last refreshed the connection. + @Column({ name: 'connected_by_user_id', type: 'uuid', nullable: true }) + connectedByUserId: string | null; + + @Column({ name: 'connected_by_email', type: 'varchar', length: 320, nullable: true }) + connectedByEmail: string | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/backend/src/entities/news-item.entity.ts b/backend/src/entities/news-item.entity.ts new file mode 100644 index 0000000..d834500 --- /dev/null +++ b/backend/src/entities/news-item.entity.ts @@ -0,0 +1,25 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity('news_items') +export class NewsItem { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ length: 500 }) + text: string; + + @Column({ name: 'display_date', type: 'date' }) + displayDate: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/backend/src/entities/order.entity.ts b/backend/src/entities/order.entity.ts new file mode 100644 index 0000000..0ff545a --- /dev/null +++ b/backend/src/entities/order.entity.ts @@ -0,0 +1,54 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { User } from './user.entity'; +import { ShopItem } from './shop-item.entity'; + +@Entity('orders') +export class Order { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'user_id' }) + userId: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: User; + + @Column({ name: 'shop_item_id', nullable: true }) + shopItemId: string | null; + + @ManyToOne(() => ShopItem, { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'shop_item_id' }) + shopItem: ShopItem; + + @Column({ type: 'integer' }) + quantity: number; + + @Column({ name: 'pipes_spent', type: 'integer' }) + pipesSpent: number; + + @Column({ name: 'item_name', length: 200 }) + itemName: string; + + @Column({ length: 20, default: 'pending' }) + status: string; // 'pending' | 'fulfilled' + + // Public ID (cdg_…) of the HCB card grant issued for this order, if any. + // Acts as a per-order idempotency lock: a non-null value blocks re-granting. + @Column({ name: 'hcb_card_grant_id', type: 'varchar', length: 64, nullable: true }) + hcbCardGrantId: string | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/backend/src/entities/project-review.entity.ts b/backend/src/entities/project-review.entity.ts new file mode 100644 index 0000000..3a5c63b --- /dev/null +++ b/backend/src/entities/project-review.entity.ts @@ -0,0 +1,53 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Project } from './project.entity'; +import { Submission } from './submission.entity'; +import { User } from './user.entity'; + +@Entity('project_reviews') +export class ProjectReview { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'project_id' }) + projectId: string; + + @ManyToOne(() => Project, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'project_id' }) + project: Project; + + @Column({ name: 'reviewer_id', nullable: true }) + reviewerId: string | null; + + @ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'reviewer_id' }) + reviewer: User | null; + + @Column({ name: 'submission_id', nullable: true }) + submissionId: string | null; + + @ManyToOne(() => Submission, { onDelete: 'CASCADE', nullable: true }) + @JoinColumn({ name: 'submission_id' }) + submission: Submission; + + @Column({ length: 20 }) + status: string; + + @Column({ type: 'text', nullable: true }) + feedback: string | null; + + @Column({ type: 'text', name: 'internal_note', nullable: true }) + internalNote: string | null; + + @Column({ type: 'text', name: 'override_justification', nullable: true }) + overrideJustification: string | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; +} diff --git a/backend/src/entities/project.entity.ts b/backend/src/entities/project.entity.ts new file mode 100644 index 0000000..f6a5556 --- /dev/null +++ b/backend/src/entities/project.entity.ts @@ -0,0 +1,110 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { User } from './user.entity'; + +export const VALID_PROJECT_STATUSES = [ + 'unshipped', + 'unreviewed', + 'fraud_pending', + 'changes_needed', + 'approved', +] as const; + +export type ProjectStatus = (typeof VALID_PROJECT_STATUSES)[number]; + +export const VALID_PROJECT_TYPES = [ + 'web', + 'windows', + 'mac', + 'linux', + 'cross-platform', + 'python', + 'android', + 'ios', + 'hardware', + 'cad', + 'other', +] as const; + +export type ProjectType = (typeof VALID_PROJECT_TYPES)[number]; + +@Entity('projects') +export class Project { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'user_id' }) + userId: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: User; + + @Column({ length: 50 }) + name: string; + + @Column({ length: 300 }) + description: string; + + @Column({ name: 'project_type', length: 20 }) + projectType: string; + + @Column({ type: 'varchar', name: 'code_url', length: 2048, nullable: true }) + codeUrl: string | null; + + @Column({ type: 'varchar', name: 'readme_url', length: 2048, nullable: true }) + readmeUrl: string | null; + + @Column({ type: 'varchar', name: 'demo_url', length: 2048, nullable: true }) + demoUrl: string | null; + + @Column({ type: 'varchar', name: 'screenshot_1_url', length: 2048, nullable: true }) + screenshot1Url: string | null; + + @Column({ type: 'varchar', name: 'screenshot_2_url', length: 2048, nullable: true }) + screenshot2Url: string | null; + + @Column({ type: 'text', name: 'hackatime_project_name', nullable: true, transformer: { + to: (value: string[] | null) => value && value.length > 0 ? JSON.stringify(value) : null, + from: (value: string | null) => { + if (!value) return []; + try { const parsed = JSON.parse(value); return Array.isArray(parsed) ? parsed : [value]; } + catch { return [value]; } + }, + }}) + hackatimeProjectName: string[]; + + @Column({ name: 'status', length: 20, default: 'unshipped' }) + status: string; + + @Column({ type: 'real', name: 'override_hours', nullable: true }) + overrideHours: number | null; + + @Column({ type: 'real', name: 'internal_hours', nullable: true }) + internalHours: number | null; + + @Column({ name: 'is_update', default: false }) + isUpdate: boolean; + + @Column({ type: 'varchar', name: 'other_hc_program', length: 255, nullable: true }) + otherHcProgram: string | null; + + @Column({ type: 'varchar', name: 'ai_use', length: 200, nullable: true }) + aiUse: string | null; + + @Column({ type: 'integer', name: 'pipes_granted', default: 0 }) + pipesGranted: number; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/backend/src/entities/session.entity.ts b/backend/src/entities/session.entity.ts new file mode 100644 index 0000000..06a8231 --- /dev/null +++ b/backend/src/entities/session.entity.ts @@ -0,0 +1,31 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { User } from './user.entity'; + +@Entity('sessions') +export class Session { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'user_id' }) + userId: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: User; + + @Column({ unique: true, name: 'refresh_token_hash' }) + refreshTokenHash: string; + + @Column({ name: 'expires_at' }) + expiresAt: Date; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; +} diff --git a/backend/src/entities/shop-item.entity.ts b/backend/src/entities/shop-item.entity.ts new file mode 100644 index 0000000..5d9a8b2 --- /dev/null +++ b/backend/src/entities/shop-item.entity.ts @@ -0,0 +1,49 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity('shop_items') +export class ShopItem { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ length: 200 }) + name: string; + + @Column({ length: 500 }) + description: string; + + @Column({ name: 'image_url', length: 500 }) + imageUrl: string; + + @Column({ name: 'price_hours', type: 'integer' }) + priceHours: number; + + @Column({ type: 'integer', nullable: true, default: null }) + stock: number | null; + + @Column({ name: 'sort_order', type: 'integer', default: 0 }) + sortOrder: number; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_featured', type: 'boolean', default: false }) + isFeatured: boolean; + + @Column({ name: 'detailed_description', type: 'text', nullable: true, default: null }) + detailedDescription: string | null; + + @Column({ name: 'estimated_ship', type: 'varchar', length: 200, nullable: true, default: null }) + estimatedShip: string | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/backend/src/entities/shop-suggestion-vote.entity.ts b/backend/src/entities/shop-suggestion-vote.entity.ts new file mode 100644 index 0000000..9fee43a --- /dev/null +++ b/backend/src/entities/shop-suggestion-vote.entity.ts @@ -0,0 +1,35 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { User } from './user.entity'; +import { ShopSuggestion } from './shop-suggestion.entity'; + +@Entity('shop_suggestion_votes') +@Unique('UQ_shop_suggestion_votes_user_suggestion', ['userId', 'suggestionId']) +export class ShopSuggestionVote { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'suggestion_id' }) + suggestionId: string; + + @ManyToOne(() => ShopSuggestion, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'suggestion_id' }) + suggestion: ShopSuggestion; + + @Column({ name: 'user_id' }) + userId: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: User; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; +} diff --git a/backend/src/entities/shop-suggestion.entity.ts b/backend/src/entities/shop-suggestion.entity.ts new file mode 100644 index 0000000..487869b --- /dev/null +++ b/backend/src/entities/shop-suggestion.entity.ts @@ -0,0 +1,28 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { User } from './user.entity'; + +@Entity('shop_suggestions') +export class ShopSuggestion { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'user_id' }) + userId: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: User; + + @Column({ type: 'varchar', length: 200 }) + text: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; +} diff --git a/backend/src/entities/submission.entity.ts b/backend/src/entities/submission.entity.ts new file mode 100644 index 0000000..299be63 --- /dev/null +++ b/backend/src/entities/submission.entity.ts @@ -0,0 +1,54 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Project } from './project.entity'; +import { User } from './user.entity'; + +@Entity('submissions') +export class Submission { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'project_id' }) + projectId: string; + + @ManyToOne(() => Project, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'project_id' }) + project: Project; + + @Column({ name: 'user_id' }) + userId: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: User; + + @Column({ type: 'text', name: 'change_description', nullable: true }) + changeDescription: string | null; + + @Column({ name: 'min_hours_confirmed', default: false }) + minHoursConfirmed: boolean; + + @Column({ type: 'text', name: 'reviewer_note', nullable: true }) + reviewerNote: string | null; + + @Column({ length: 20, default: 'unreviewed' }) + status: string; // 'unreviewed' | 'approved' | 'changes_needed' + + @Column({ type: 'real', name: 'override_hours', nullable: true }) + overrideHours: number | null; + + @Column({ type: 'real', name: 'internal_hours', nullable: true }) + internalHours: number | null; + + @Column({ type: 'integer', name: 'pipes_granted', default: 0 }) + pipesGranted: number; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; +} diff --git a/backend/src/entities/user.entity.ts b/backend/src/entities/user.entity.ts new file mode 100644 index 0000000..ff57f6c --- /dev/null +++ b/backend/src/entities/user.entity.ts @@ -0,0 +1,97 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { encryptedTransformer } from '../crypto.util'; + +@Entity('users') +export class User { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true, name: 'hca_sub' }) + hcaSub: string; + + @Column({ type: 'text', transformer: encryptedTransformer }) + email: string; + + @Column({ nullable: true }) + name: string; + + @Column({ nullable: true }) + nickname: string; + + @Column({ nullable: true, name: 'slack_id' }) + slackId: string; + + @Column({ name: 'two_emails', default: false }) + twoEmails: boolean; + + @Column({ name: 'has_address', default: false }) + hasAddress: boolean; + + @Column({ name: 'has_birthdate', default: false }) + hasBirthdate: boolean; + + @Column({ nullable: true, name: 'hackatime_user_id' }) + hackatimeUserId: string; + + @Column({ nullable: true }) + gender: string; + + // Answer to the one-time "here for the hackathon or the shop?" home prompt. + // null = not answered yet → the modal keeps showing until they pick one. + @Column({ type: 'varchar', length: 20, nullable: true }) + intent: string | null; + + @Column({ + nullable: true, + name: 'hackatime_token', + type: 'text', + transformer: encryptedTransformer, + }) + hackatimeToken: string; + + @Column({ + nullable: true, + name: 'hca_access_token', + type: 'text', + transformer: encryptedTransformer, + }) + hcaAccessToken: string; + + @Column({ + nullable: true, + name: 'hca_refresh_token', + type: 'text', + transformer: encryptedTransformer, + }) + hcaRefreshToken: string; + + @Column({ type: 'integer', default: 0 }) + pipes: number; + + @Column({ nullable: true, name: 'utm_source' }) + utmSource: string; + + @Column({ nullable: true, name: 'utm_medium' }) + utmMedium: string; + + @Column({ nullable: true, name: 'utm_campaign' }) + utmCampaign: string; + + @Column({ nullable: true }) + referrer: string; + + @Column({ nullable: true, name: 'landing_path' }) + landingPath: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/backend/src/fetch.util.ts b/backend/src/fetch.util.ts new file mode 100644 index 0000000..3e1630f --- /dev/null +++ b/backend/src/fetch.util.ts @@ -0,0 +1,16 @@ +/** + * Wrapper around fetch with a default timeout. + * Prevents hanging requests to external services from blocking the server. + */ +export function fetchWithTimeout( + url: string | URL, + init?: RequestInit & { timeoutMs?: number }, +): Promise { + const { timeoutMs = 10000, ...fetchInit } = init ?? {}; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + return fetch(url, { ...fetchInit, signal: controller.signal }).finally(() => + clearTimeout(timer), + ); +} diff --git a/backend/src/fraud-review/fraud-review.module.ts b/backend/src/fraud-review/fraud-review.module.ts new file mode 100644 index 0000000..30163c8 --- /dev/null +++ b/backend/src/fraud-review/fraud-review.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Project } from '../entities/project.entity'; +import { User } from '../entities/user.entity'; +import { Devlog } from '../entities/devlog.entity'; +import { FraudReview } from '../entities/fraud-review.entity'; +import { ProjectReview } from '../entities/project-review.entity'; +import { Submission } from '../entities/submission.entity'; +import { AuditLogModule } from '../audit-log/audit-log.module'; +import { ProjectAirtableSyncModule } from '../projects/project-airtable-sync.module'; +import { RsvpModule } from '../rsvp/rsvp.module'; +import { FraudReviewService } from './fraud-review.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Project, User, Devlog, FraudReview, ProjectReview, Submission]), + AuditLogModule, + ProjectAirtableSyncModule, + RsvpModule, + ], + providers: [FraudReviewService], + exports: [FraudReviewService], +}) +export class FraudReviewModule {} diff --git a/backend/src/fraud-review/fraud-review.service.ts b/backend/src/fraud-review/fraud-review.service.ts new file mode 100644 index 0000000..de1fc1e --- /dev/null +++ b/backend/src/fraud-review/fraud-review.service.ts @@ -0,0 +1,668 @@ +import { + Injectable, + Logger, + OnApplicationBootstrap, + OnApplicationShutdown, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { IsNull, Repository } from 'typeorm'; +import { Project } from '../entities/project.entity'; +import { User } from '../entities/user.entity'; +import { Devlog } from '../entities/devlog.entity'; +import { FraudReview } from '../entities/fraud-review.entity'; +import { ProjectReview } from '../entities/project-review.entity'; +import { Submission } from '../entities/submission.entity'; +import { fetchWithTimeout } from '../fetch.util'; +import { AuditLogService } from '../audit-log/audit-log.service'; +import { ProjectAirtableSyncService } from '../projects/project-airtable-sync.service'; +import { RsvpService } from '../rsvp/rsvp.service'; + +const POLL_INTERVAL_MS = 5 * 60 * 1000; +const FRAUD_REJECT_THRESHOLD = 4; +const USER_FACING_FRAUD_FEEDBACK = + 'This project was flagged for review and could not be approved at this time. Please reach out to the organizers if you believe this was in error.'; + +interface JoeFraudListProject { + id: string; + organizerPlatformId?: string | null; + status: 'pending' | 'complete'; + review?: { + trustScore: number; + justification: string; + reviewedAt: string; + } | null; + outcome?: { status: 'approved' | 'rejected' } | null; +} + +interface HardwareJournalEntry { + title: string; + content: string; + timestamp: string; + hours: number; + images?: string[]; +} + +@Injectable() +export class FraudReviewService implements OnApplicationBootstrap, OnApplicationShutdown { + private readonly logger = new Logger(FraudReviewService.name); + private readonly apiBaseUrl: string; + private readonly apiKey: string | undefined; + private readonly eventId: string | undefined; + private readonly hackatimeBaseUrl: string; + private readonly hackatimeAdminKey: string | undefined; + private readonly configured: boolean; + + private pollTimer: NodeJS.Timeout | null = null; + private polling = false; + + constructor( + private readonly config: ConfigService, + @InjectRepository(Project) private readonly projectRepo: Repository, + @InjectRepository(User) private readonly userRepo: Repository, + @InjectRepository(Devlog) private readonly devlogRepo: Repository, + @InjectRepository(FraudReview) private readonly fraudRepo: Repository, + @InjectRepository(ProjectReview) private readonly reviewRepo: Repository, + @InjectRepository(Submission) private readonly submissionRepo: Repository, + private readonly auditLogService: AuditLogService, + private readonly airtableSync: ProjectAirtableSyncService, + private readonly rsvpService: RsvpService, + ) { + this.apiBaseUrl = ( + this.config.get('FRAUD_REVIEW_API_URL') ?? + 'https://joe.fraud.hackclub.com/api/v1/ysws' + ).replace(/\/+$/, ''); + this.apiKey = this.config.get('FRAUD_REVIEW_API_KEY'); + this.eventId = this.config.get('FRAUD_REVIEW_EVENT_ID'); + this.hackatimeBaseUrl = this.config.get( + 'HACKATIME_BASE_URL', + 'https://hackatime.hackclub.com', + ); + this.hackatimeAdminKey = this.config.get('HACKATIME_ADMIN_API_KEY'); + this.configured = !!(this.apiKey && this.eventId); + if (!this.configured) { + this.logger.warn( + 'FRAUD_REVIEW_API_KEY/FRAUD_REVIEW_EVENT_ID not set — fraud review integration disabled', + ); + } + } + + // ── Lifecycle ────────────────────────────────────────────────────────────── + + onApplicationBootstrap() { + return; + } + + onApplicationShutdown() { + if (this.pollTimer) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + } + + // ── Public API (called from AdminService when a beest reviewer approves) ─── + + /** + * Idempotently records that a project has been approved by beest review and + * is now waiting for joe.fraud's first-pass verdict. The actual upstream + * Create Project call is deferred to the poller so the reviewer's request is + * never blocked by joe.fraud's availability. + */ + async stageProjectForReview(projectId: string): Promise { + const existing = await this.fraudRepo.findOne({ where: { projectId } }); + if (existing) { + // Re-stage — clear any prior verdict so the poller treats it as fresh. + existing.remoteProjectId = null; + existing.status = 'pending'; + existing.trustScore = null; + existing.justification = null; + existing.reviewedAt = null; + existing.outcomeRecorded = false; + await this.fraudRepo.save(existing); + return; + } + const row = this.fraudRepo.create({ projectId, status: 'pending' }); + await this.fraudRepo.save(row); + } + + // ── Poller ───────────────────────────────────────────────────────────────── + + /** Public so an admin endpoint can trigger a manual reconcile if needed. */ + async poll(): Promise { + if (!this.configured) return; + if (this.polling) return; // skip overlapping cycles + this.polling = true; + try { + // 1. Submit any rows that haven't been posted upstream yet. + const unsubmitted = await this.fraudRepo.find({ + where: { remoteProjectId: IsNull(), status: 'pending' }, + }); + for (const row of unsubmitted) { + try { + await this.submitOne(row); + } catch (err) { + this.logger.error( + `Fraud submit failed for project ${row.projectId}: ${err}`, + ); + } + } + + // 2. Reconcile pending verdicts. + const remoteList = await this.listRemoteProjects(); + if (!remoteList) return; + const byId = new Map(remoteList.map((p) => [p.id, p])); + + const pending = await this.fraudRepo.find({ + where: { status: 'pending' }, + }); + for (const row of pending) { + if (!row.remoteProjectId) continue; + const remote = byId.get(row.remoteProjectId); + if (!remote || remote.status !== 'complete' || !remote.review) continue; + try { + await this.reconcile(row, remote); + } catch (err) { + this.logger.error( + `Fraud reconcile failed for project ${row.projectId}: ${err}`, + ); + } + } + } finally { + this.polling = false; + } + } + + // ── Submit ───────────────────────────────────────────────────────────────── + + private async submitOne(row: FraudReview): Promise { + const project = await this.projectRepo.findOne({ + where: { id: row.projectId }, + relations: ['user'], + }); + if (!project || !project.user) { + this.logger.warn( + `Cannot submit fraud review for ${row.projectId} — project or user missing`, + ); + return; + } + + const submitter = this.buildSubmitter(project.user); + if (!submitter) { + this.logger.warn( + `Cannot submit fraud review for ${row.projectId} — no slackId or email on user`, + ); + return; + } + + const isHardware = project.projectType === 'hardware'; + + const body: Record = { + name: project.name, + codeLink: project.codeUrl ?? '', + submitter, + organizerPlatformId: project.id, + }; + if (project.demoUrl) body.demoLink = project.demoUrl; + + if (isHardware) { + body.isHardware = true; + body.hardwareJournal = await this.buildHardwareJournal(project); + } else if (project.hackatimeProjectName?.length) { + body.hackatimeProjects = project.hackatimeProjectName; + } + + const res = await fetchWithTimeout( + `${this.apiBaseUrl}/events/${encodeURIComponent(this.eventId!)}/projects`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }, + ); + + const respBody = await res.json().catch(() => null); + if (!res.ok && res.status !== 200) { + this.logger.error( + `joe.fraud create failed (${res.status}) for ${row.projectId}: ${JSON.stringify(respBody)}`, + ); + return; + } + const remoteId = respBody?.id; + if (typeof remoteId !== 'string' || remoteId.length === 0) { + this.logger.error( + `joe.fraud create returned no id for ${row.projectId}: ${JSON.stringify(respBody)}`, + ); + return; + } + row.remoteProjectId = remoteId; + await this.fraudRepo.save(row); + this.logger.log( + `Submitted project ${project.id} to joe.fraud as ${remoteId}`, + ); + } + + private buildSubmitter( + user: User, + ): { slackId: string } | { email: string } | null { + if (user.slackId) return { slackId: user.slackId }; + if (user.email) return { email: user.email }; + return null; + } + + // ── List ─────────────────────────────────────────────────────────────────── + + private async listRemoteProjects(): Promise { + const res = await fetchWithTimeout( + `${this.apiBaseUrl}/events/${encodeURIComponent(this.eventId!)}/projects`, + { headers: { Authorization: `Bearer ${this.apiKey}` } }, + ); + if (!res.ok) { + const text = await res.text().catch(() => ''); + this.logger.error(`joe.fraud list failed (${res.status}): ${text}`); + return null; + } + const body = await res.json().catch(() => null); + const raw = body?.projects; + if (!Array.isArray(raw)) { + this.logger.error(`joe.fraud list returned non-array body`); + return null; + } + + // Defensively validate each entry's shape — only act on records that + // pass schema checks. A malformed entry is logged and dropped. + const out: JoeFraudListProject[] = []; + for (const entry of raw) { + const validated = this.validateRemoteProject(entry); + if (validated) out.push(validated); + } + return out; + } + + /** + * Validate that a list entry from joe.fraud has the structure we expect + * before we let it influence beest state. Returns a typed object on success + * or null if the entry is malformed. + */ + private validateRemoteProject(entry: unknown): JoeFraudListProject | null { + if (!entry || typeof entry !== 'object') return null; + const e = entry as Record; + + if (typeof e.id !== 'string' || e.id.length === 0 || e.id.length > 64) { + return null; + } + if (e.status !== 'pending' && e.status !== 'complete') return null; + + let review: JoeFraudListProject['review'] = null; + if (e.review && typeof e.review === 'object') { + const r = e.review as Record; + if ( + typeof r.trustScore !== 'number' || + !Number.isInteger(r.trustScore) || + r.trustScore < 1 || + r.trustScore > 10 + ) { + return null; + } + if (typeof r.justification !== 'string') return null; + if (typeof r.reviewedAt !== 'string') return null; + review = { + trustScore: r.trustScore, + // Cap justification length to 4 KB — defense against pathological + // payloads that would bloat our DB or audit logs. + justification: r.justification.slice(0, 4000), + reviewedAt: r.reviewedAt, + }; + } + + return { + id: e.id, + organizerPlatformId: + typeof e.organizerPlatformId === 'string' ? e.organizerPlatformId : null, + status: e.status as 'pending' | 'complete', + review, + outcome: null, // not used in reconcile path + }; + } + + // ── Reconcile ────────────────────────────────────────────────────────────── + + private async reconcile( + row: FraudReview, + remote: JoeFraudListProject, + ): Promise { + const review = remote.review!; + + // Defense in depth: confirm the remote record is actually for this beest + // project. If joe.fraud's response had an organizerPlatformId that doesn't + // match our project id, refuse to act and log loudly. (The list endpoint + // map is keyed by remote id, but we still verify identity before mutating + // local state to make response-tampering immediately obvious.) + if ( + remote.organizerPlatformId && + remote.organizerPlatformId !== row.projectId + ) { + this.logger.error( + `Refusing to reconcile ${row.projectId}: remote ${remote.id} reports organizerPlatformId=${remote.organizerPlatformId}`, + ); + return; + } + + row.status = 'complete'; + row.trustScore = review.trustScore; + row.justification = review.justification; + row.reviewedAt = new Date(review.reviewedAt); + await this.fraudRepo.save(row); + + // If the beest reviewer flipped the project out of 'fraud_pending' (e.g. + // back to changes_needed) between submit and verdict, don't act on the + // verdict — record it on our row but leave the project alone. + const project = await this.projectRepo.findOne({ + where: { id: row.projectId }, + }); + if (!project || project.status !== 'fraud_pending') { + this.logger.log( + `Skipping reconcile side-effects for ${row.projectId} — current status is ${project?.status ?? 'missing'}`, + ); + return; + } + + if (review.trustScore <= FRAUD_REJECT_THRESHOLD) { + await this.markFraudRejected(row, review.justification); + return; + } + + await this.completeApproval(row); + } + + private async markFraudRejected( + row: FraudReview, + justification: string, + ): Promise { + const project = await this.projectRepo.findOne({ + where: { id: row.projectId }, + relations: ['user'], + }); + if (!project) return; + + project.status = 'changes_needed'; + // Beest review previously bumped overrideHours; clear since the project is + // now rejected. (No pipes were granted yet — that only happens after fraud + // passes — so there's nothing to claw back.) + project.overrideHours = 0; + project.internalHours = 0; + await this.projectRepo.save(project); + + // Also bump the latest submission back to 'changes_needed' so the user + // sees the project as needing changes in the dashboard. + const submission = await this.submissionRepo.findOne({ + where: { projectId: row.projectId }, + order: { createdAt: 'DESC' }, + }); + if (submission && submission.status !== 'changes_needed') { + submission.status = 'changes_needed'; + submission.overrideHours = 0; + submission.internalHours = 0; + await this.submissionRepo.save(submission); + } + + // Surface a generic message to the user; keep the upstream justification + // internal so reviewers can see the real reason without exposing it. + const review = this.reviewRepo.create({ + projectId: row.projectId, + reviewerId: null, // system-authored — no human reviewer + submissionId: submission?.id ?? null, + status: 'changes_needed', + feedback: USER_FACING_FRAUD_FEEDBACK, + internalNote: `Fraud review flagged: ${justification}`, + overrideJustification: null, + }); + await this.reviewRepo.save(review); + + await this.auditLogService.log( + project.userId, + 'project_reviewed', + `Project "${project.name}" was flagged by fraud review`, + ); + this.logger.log( + `Fraud-rejected project ${project.id} (trust=${row.trustScore})`, + ); + } + + private async completeApproval(row: FraudReview): Promise { + const project = await this.projectRepo.findOne({ + where: { id: row.projectId }, + relations: ['user'], + }); + if (!project) return; + + // 1. Flip status to approved. + project.status = 'approved'; + await this.projectRepo.save(project); + + // 2. Find the latest submission for this project (the one that was just + // fraud-approved). It currently sits at status='unreviewed' because + // AdminService deferred the flip to here. + const submission = await this.submissionRepo.findOne({ + where: { projectId: row.projectId }, + order: { createdAt: 'DESC' }, + }); + if (submission && submission.status !== 'approved') { + submission.status = 'approved'; + await this.submissionRepo.save(submission); + } + + // 3. Grant pipes — same delta logic as AdminService.reviewProject. Target = + // floor(sum of override_hours across the user's earned projects), delta = + // target − sum(pipes_granted) across all the user's projects. + if ((project.overrideHours ?? 0) > 0) { + const totals = await this.projectRepo + .createQueryBuilder('p') + .select('COALESCE(SUM(p.override_hours), 0)', 'earnedHours') + .addSelect('COALESCE(SUM(p.pipes_granted), 0)', 'granted') + .where('p.user_id = :uid', { uid: project.userId }) + .andWhere( + `(p.status = 'approved' OR (p.status <> 'approved' AND p.pipes_granted > 0))`, + ) + .getRawOne<{ earnedHours: string; granted: string }>(); + const target = Math.floor(Number(totals?.earnedHours ?? 0)); + const previouslyGranted = Number(totals?.granted ?? 0); + const delta = target - previouslyGranted; + if (delta > 0) { + await this.userRepo.increment({ id: project.userId }, 'pipes', delta); + project.pipesGranted = (project.pipesGranted ?? 0) + delta; + await this.projectRepo.save(project); + if (submission) { + submission.pipesGranted = delta; + await this.submissionRepo.save(submission); + } + } + } + + // 4. Find the review record the beest reviewer just created so we can pass + // its overrideJustification to the Airtable sync, with the fraud + // reviewer's justification appended for downstream traceability. + const beestReview = await this.reviewRepo.findOne({ + where: { projectId: row.projectId, status: 'approved' }, + order: { createdAt: 'DESC' }, + }); + const combinedJustification = this.combineJustifications( + beestReview?.overrideJustification ?? null, + row.trustScore, + row.justification, + ); + + // 5. Loops sync + Airtable Projects push. + if (project.user?.email) { + this.rsvpService.updateDateField( + project.user.email, + 'Loops - beestApprovedProject', + ); + } + try { + await this.airtableSync.syncApprovedProject( + project, + combinedJustification, + submission ?? null, + ); + } catch (err) { + this.logger.error( + `Airtable sync failed for fraud-approved project ${project.id}: ${err}`, + ); + } + + // 6. Audit log + outcome callback. + await this.auditLogService.log( + project.userId, + 'project_reviewed', + `Project "${project.name}" was approved (fraud-cleared)`, + ); + + if (!row.outcomeRecorded && row.remoteProjectId) { + try { + await this.recordOutcome(row.remoteProjectId, 'approved'); + row.outcomeRecorded = true; + await this.fraudRepo.save(row); + } catch (err) { + this.logger.error( + `joe.fraud outcome push failed for ${project.id}: ${err}`, + ); + } + } + + this.logger.log( + `Fraud-approved project ${project.id} (trust=${row.trustScore})`, + ); + } + + private combineJustifications( + beest: string | null, + fraudScore: number | null, + fraudJustification: string | null, + ): string | null { + const parts: string[] = []; + if (beest && beest.trim().length > 0) parts.push(beest.trim()); + if (fraudJustification && fraudJustification.trim().length > 0) { + const scoreLabel = fraudScore != null ? `trust ${fraudScore}/10` : 'verdict'; + parts.push(`Fraud review (${scoreLabel}): ${fraudJustification.trim()}`); + } + return parts.length > 0 ? parts.join('\n\n') : null; + } + + private async recordOutcome( + remoteProjectId: string, + status: 'approved' | 'rejected', + reason?: string, + ): Promise { + const body: Record = { status }; + if (status === 'rejected' && reason) body.reason = reason; + const res = await fetchWithTimeout( + `${this.apiBaseUrl}/events/${encodeURIComponent(this.eventId!)}/projects/${encodeURIComponent(remoteProjectId)}/outcome`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }, + ); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`outcome ${res.status}: ${text}`); + } + } + + // ── Hardware journal mapping ─────────────────────────────────────────────── + + private async buildHardwareJournal( + project: Project, + ): Promise { + const devlogs = await this.devlogRepo.find({ + where: { projectId: project.id }, + order: { createdAt: 'ASC' }, + }); + if (devlogs.length === 0) return []; + + // Bucket devlogs by UTC date so we can spread that day's Hackatime hours + // across the entries written on the same day. + const devlogsByDay = new Map(); + for (const d of devlogs) { + const day = d.createdAt.toISOString().slice(0, 10); + const bucket = devlogsByDay.get(day) ?? []; + bucket.push(d); + devlogsByDay.set(day, bucket); + } + + const hoursByDay = await this.hoursPerDayFromHackatime(project); + + return devlogs.map((d) => { + const day = d.createdAt.toISOString().slice(0, 10); + const dayHours = hoursByDay.get(day) ?? 0; + const sameDayCount = devlogsByDay.get(day)?.length ?? 1; + const hours = sameDayCount > 0 ? dayHours / sameDayCount : 0; + + return { + title: d.title, + content: d.text, + timestamp: d.createdAt.toISOString(), + hours: Math.round(hours * 100) / 100, + ...(d.imageUrls && d.imageUrls.length > 0 ? { images: d.imageUrls } : {}), + }; + }); + } + + private async hoursPerDayFromHackatime( + project: Project, + ): Promise> { + const out = new Map(); + const names = project.hackatimeProjectName ?? []; + if (names.length === 0 || !this.hackatimeAdminKey) return out; + + const user = await this.userRepo.findOne({ + where: { id: project.userId }, + select: ['id', 'hackatimeUserId'], + }); + const htUserId = user?.hackatimeUserId; + if (!htUserId) return out; + + // Fetch a wide window — start_date is bounded by the project's earliest + // devlog (or createdAt), end_date is today + 1 day. + const startMs = Math.min( + project.createdAt?.getTime() ?? Date.now(), + Date.now(), + ); + const startDate = new Date(startMs).toISOString().slice(0, 10); + const endDate = new Date(Date.now() + 86400_000).toISOString().slice(0, 10); + + for (const name of names) { + try { + const res = await fetchWithTimeout( + `${this.hackatimeBaseUrl}/api/v1/users/${encodeURIComponent(htUserId)}/heartbeats/spans` + + `?start_date=${startDate}&end_date=${endDate}` + + `&project=${encodeURIComponent(name)}`, + { headers: { Authorization: `Bearer ${this.hackatimeAdminKey}` } }, + ); + if (!res.ok) continue; + const body = await res.json().catch(() => null); + const spans: { start_time?: number; duration?: number }[] = body?.spans ?? []; + for (const span of spans) { + const t = span.start_time; + const d = span.duration; + if (typeof t !== 'number' || typeof d !== 'number') continue; + if (!Number.isFinite(t) || !Number.isFinite(d) || d <= 0) continue; + const ms = t > 1e12 ? t : t * 1000; + const day = new Date(ms).toISOString().slice(0, 10); + out.set(day, (out.get(day) ?? 0) + d / 3600); + } + } catch (err) { + this.logger.warn( + `Hackatime spans fetch failed for ${project.id}/${name}: ${err}`, + ); + } + } + return out; + } +} diff --git a/backend/src/hackatime/hackatime.controller.ts b/backend/src/hackatime/hackatime.controller.ts new file mode 100644 index 0000000..6142b07 --- /dev/null +++ b/backend/src/hackatime/hackatime.controller.ts @@ -0,0 +1,87 @@ +import { + Controller, + Post, + Get, + Body, + Req, + UseGuards, + UnauthorizedException, + BadRequestException, +} from '@nestjs/common'; +import { Throttle } from '@nestjs/throttler'; +import type { Request } from 'express'; +import { HackatimeService } from './hackatime.service'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; + +@Controller('api/hackatime') +export class HackatimeController { + constructor(private readonly hackatimeService: HackatimeService) {} + + /** + * Generates Hackatime OAuth state and authorization URL. + * Requires an authenticated user (must be logged in via HCA first). + */ + @Throttle({ default: { limit: 10, ttl: 60000 } }) + @UseGuards(JwtAuthGuard) + @Post('start') + start() { + return this.hackatimeService.startAuth(); + } + + /** + * Handles the Hackatime OAuth callback: state verification, code exchange, + * and storing the connection for the authenticated user. + */ + @Throttle({ default: { limit: 10, ttl: 60000 } }) + @UseGuards(JwtAuthGuard) + @Post('callback') + async handleCallback( + @Req() req: Request, + @Body() + body: { + code: string; + state: string; + storedState: string; + }, + ) { + if (!body.code) { + throw new BadRequestException('Authorization code is required'); + } + if (!body.state || !body.storedState) { + throw new BadRequestException('State parameters are required'); + } + + const user = (req as any).user; + const userId = user?.sub; + if (!userId) { + throw new UnauthorizedException('No user identity'); + } + + try { + return await this.hackatimeService.handleCallback( + body.code, + body.state, + body.storedState, + userId, + user.impersonator_name, + ); + } catch { + throw new UnauthorizedException('Hackatime authentication failed'); + } + } + + /** + * Returns the authenticated user's Hackatime project names. + * Only project name strings are returned — no other Hackatime data. + */ + @Throttle({ default: { limit: 15, ttl: 60000 } }) + @UseGuards(JwtAuthGuard) + @Get('projects') + async getProjects(@Req() req: Request) { + const userId = (req as any).user?.sub; + if (!userId) throw new UnauthorizedException('No user identity'); + + const names = await this.hackatimeService.getProjectNames(userId); + return { projects: names }; + } +} diff --git a/backend/src/hackatime/hackatime.module.ts b/backend/src/hackatime/hackatime.module.ts new file mode 100644 index 0000000..b3c1e70 --- /dev/null +++ b/backend/src/hackatime/hackatime.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthModule } from '../auth/auth.module'; +import { AuditLogModule } from '../audit-log/audit-log.module'; +import { RsvpModule } from '../rsvp/rsvp.module'; +import { User } from '../entities/user.entity'; +import { Session } from '../entities/session.entity'; +import { HackatimeService } from './hackatime.service'; +import { HackatimeController } from './hackatime.controller'; + +@Module({ + imports: [AuthModule, AuditLogModule, RsvpModule, TypeOrmModule.forFeature([User, Session])], + controllers: [HackatimeController], + providers: [HackatimeService], + exports: [HackatimeService], +}) +export class HackatimeModule {} diff --git a/backend/src/hackatime/hackatime.service.ts b/backend/src/hackatime/hackatime.service.ts new file mode 100644 index 0000000..7dd1b00 --- /dev/null +++ b/backend/src/hackatime/hackatime.service.ts @@ -0,0 +1,681 @@ +import { ForbiddenException, Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Cron } from '@nestjs/schedule'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { createHmac, timingSafeEqual } from 'crypto'; +import { IsNull } from 'typeorm'; +import { fetchWithTimeout } from '../fetch.util'; +import { User } from '../entities/user.entity'; +import { Session } from '../entities/session.entity'; +import { AuditLogService } from '../audit-log/audit-log.service'; +import { RsvpService } from '../rsvp/rsvp.service'; + +type OwnershipLookups = + | { + ok: true; + emailOwnerId: string | null; + linkedTrustLevel: string | null; + linkedBanned: boolean; + linkedEmails: string[]; + } + | { ok: false; reason: string }; + +type OwnershipVerdict = + | { kind: 'pass'; reason: string } + | { kind: 'ban'; reason: string } + | { kind: 'inconclusive' }; + +@Injectable() +export class HackatimeService implements OnModuleInit { + private readonly logger = new Logger(HackatimeService.name); + private readonly clientId: string | undefined; + private readonly clientSecret: string | undefined; + private readonly redirectUri: string; + private readonly jwtSecret: string; + private readonly baseUrl: string; + private readonly adminApiKey: string | undefined; + private readonly configured: boolean; + + constructor( + private configService: ConfigService, + @InjectRepository(User) + private userRepo: Repository, + @InjectRepository(Session) + private sessionRepo: Repository, + private auditLogService: AuditLogService, + private rsvpService: RsvpService, + ) { + this.clientId = this.configService.get('HACKATIME_CLIENT_ID'); + this.clientSecret = this.configService.get('HACKATIME_CLIENT_SECRET'); + this.redirectUri = this.configService.get( + 'HACKATIME_REDIRECT_URI', + 'http://localhost:5173/auth/hackatime/callback', + ); + this.jwtSecret = this.configService.getOrThrow('JWT_SECRET'); + this.baseUrl = this.configService.get( + 'HACKATIME_BASE_URL', + 'https://hackatime.hackclub.com', + ); + const rawAdminKey = this.configService.get('HACKATIME_ADMIN_API_KEY'); + this.adminApiKey = rawAdminKey?.trim() || undefined; + this.configured = !!(this.clientId && this.clientSecret); + if (!this.configured) { + this.logger.warn('HACKATIME_CLIENT_ID/SECRET not set — Hackatime OAuth disabled'); + } + } + + private assertConfigured(): void { + if (!this.configured) { + throw new Error('Hackatime OAuth is not configured'); + } + } + + private signState(state: string): string { + // Prefix with flow name to prevent cross-flow state confusion with HCA OAuth + return createHmac('sha256', this.jwtSecret) + .update(`hackatime:${state}`) + .digest('hex'); + } + + startAuth(): { url: string; state: string } { + this.assertConfigured(); + + const state = crypto.randomUUID(); + const signature = this.signState(state); + const signedState = `${state}.${signature}`; + + const params = new URLSearchParams({ + client_id: this.clientId!, + redirect_uri: this.redirectUri, + response_type: 'code', + scope: 'profile read', + state: signedState, + }); + + return { + url: `${this.baseUrl}/oauth/authorize?${params.toString()}`, + state, + }; + } + + async handleCallback( + code: string, + returnedSignedState: string, + cookieState: string, + userId: string, + impersonatorName?: string, + ): Promise<{ success: boolean; redirectTo: string }> { + this.assertConfigured(); + + // 1. Verify state (same HMAC pattern as HCA OAuth) + const dotIndex = returnedSignedState.lastIndexOf('.'); + if (dotIndex === -1) { + throw new Error('Malformed state parameter'); + } + + const stateValue = returnedSignedState.substring(0, dotIndex); + const signature = returnedSignedState.substring(dotIndex + 1); + + const stateBuffer = Buffer.from(stateValue); + const cookieBuffer = Buffer.from(cookieState); + if ( + stateBuffer.length !== cookieBuffer.length || + !timingSafeEqual(stateBuffer, cookieBuffer) + ) { + throw new Error('State mismatch'); + } + + const expectedSignature = this.signState(stateValue); + const sigBuffer = Buffer.from(signature); + const expectedBuffer = Buffer.from(expectedSignature); + if ( + sigBuffer.length !== expectedBuffer.length || + !timingSafeEqual(sigBuffer, expectedBuffer) + ) { + throw new Error('Invalid state signature'); + } + + // 2. Exchange code for tokens + const tokenResponse = await fetchWithTimeout(`${this.baseUrl}/oauth/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: this.redirectUri, + client_id: this.clientId!, + client_secret: this.clientSecret!, + }), + }); + + if (!tokenResponse.ok) { + this.logger.error( + `Hackatime token exchange failed: ${tokenResponse.status}`, + ); + throw new Error('Hackatime token exchange failed'); + } + + const tokens = await tokenResponse.json().catch(() => null); + + if (!tokens?.access_token) { + this.logger.error('Hackatime token response missing or malformed'); + throw new Error('Invalid token response from Hackatime'); + } + + // 3. Check if the user is banned on Hackatime + grab their Hackatime user ID + let hackatimeUid: string | null = null; + try { + const meRes = await fetchWithTimeout( + `${this.baseUrl}/api/v1/authenticated/me`, + { headers: { Authorization: `Bearer ${tokens.access_token}` } }, + ); + if (meRes.ok) { + const meData = await meRes.json(); + const d = meData?.data ?? meData; + hackatimeUid = d?.id?.toString() ?? d?.user_id?.toString() ?? null; + const trustData = d?.trust_factor ?? meData?.trust_factor; + if (trustData?.trust_level === 'red') { + this.logger.warn(`Hackatime-banned user attempted connection: ${userId}`); + const user = await this.userRepo.findOne({ where: { hcaSub: userId } }); + if (user?.email) { + await this.rsvpService.updatePerms(user.email, 'Banned'); + await this.sessionRepo.delete({ userId: user.id }); + } + return { success: false, redirectTo: 'https://fraud.hackclub.com/' }; + } + } + } catch (err) { + this.logger.error(`Hackatime ban check failed for ${userId}: ${err}`); + } + + // 4. Persist the token (and Hackatime user ID) to the user's DB record + // Use find+save (not update) so the column encryption transformer runs + const user = await this.userRepo.findOne({ where: { hcaSub: userId } }); + if (!user) { + throw new Error('User not found'); + } + user.hackatimeToken = tokens.access_token; + if (hackatimeUid) { + user.hackatimeUserId = hackatimeUid; + } + await this.userRepo.save(user); + this.logger.log(`Hackatime connected for user ${userId}`); + + await this.auditLogService.log(user.id, 'hackatime_connected', 'Connected Hackatime', impersonatorName); + + if (user.email) { + this.rsvpService.updateDateField(user.email, 'Loops - beestHackatimeSynched'); + } + + return { success: true, redirectTo: '/tutorial?stage=2' }; + } + + async isConnected(userId: string): Promise { + const user = await this.userRepo.findOne({ + where: { hcaSub: userId }, + select: ['hackatimeToken'], + }); + return !!user?.hackatimeToken; + } + + /** + * Re-checks the stored Hackatime account's current trust/ban state, since the + * connect-time check in handleCallback() is only a single snapshot. Catches + * the ban-evasion pattern where a user routes a banned Hackatime account's + * heartbeats into Beest (shared token, alt account, etc.). + * + * Only bans when the linked Hackatime account is itself banned / red-trust. + * Owning a second (non-banned) Hackatime account is allowed: a bare + * email/ID mismatch is treated as inconclusive and let through. When a ban + * does fire, it marks the Beest user as Banned in Airtable + revokes their + * sessions so the same pattern can't be retried without reconnecting. + * + * No-ops silently if the admin API key isn't configured (e.g. local dev). + */ + async verifyAccountOwnership(hcaSub: string): Promise { + if (!this.adminApiKey) { + this.logger.warn( + `Hackatime admin key not set — skipping ownership check for ${hcaSub}`, + ); + return; + } + + const user = await this.userRepo.findOne({ where: { hcaSub } }); + if (!user) throw new ForbiddenException('User not found'); + if (!user.email) { + throw new ForbiddenException('User has no email on file'); + } + if (!user.hackatimeUserId) { + throw new ForbiddenException( + 'Hackatime account not linked — please reconnect Hackatime before submitting a project.', + ); + } + + const storedId = String(user.hackatimeUserId); + const lookups = await this.fetchOwnershipLookups(user.email, storedId, hcaSub); + if (!lookups.ok) { + // Transient API issue — fail open, matches existing behavior. + this.logger.warn( + `Hackatime ownership check: ${lookups.reason} for ${hcaSub} — failing open`, + ); + return; + } + + const verdict = this.evaluateOwnership(user.email, storedId, lookups); + + if (verdict.kind === 'ban') { + this.logger.warn( + `Hackatime ownership check FAILED for ${hcaSub}: storedId=${storedId} emailOwnerId=${lookups.emailOwnerId} linkedTrust=${lookups.linkedTrustLevel} linkedBanned=${lookups.linkedBanned} linkedEmailsCount=${lookups.linkedEmails.length}`, + ); + + await this.auditLogService.log( + user.id, + 'hackatime_ownership_failed', + verdict.reason, + ); + + // Hard-ban: flip Airtable perms to Banned and nuke sessions, matching + // the handleCallback red-trust flow. + try { + await this.rsvpService.updatePerms(user.email, 'Banned'); + } catch (err) { + this.logger.error( + `Failed to flip perms to Banned for ${hcaSub}: ${err}`, + ); + } + try { + await this.sessionRepo.delete({ userId: user.id }); + } catch (err) { + this.logger.error(`Failed to revoke sessions for ${hcaSub}: ${err}`); + } + + throw new ForbiddenException( + 'Your linked Hackatime account does not match your Beest account. Please reconnect Hackatime with the correct account.', + ); + } + + if (verdict.kind === 'inconclusive') { + this.logger.warn( + `Hackatime ownership check: no positive proof for ${hcaSub} (storedId=${storedId}, emailOwnerId=${lookups.emailOwnerId}, linkedEmailsCount=${lookups.linkedEmails.length}) — failing open`, + ); + } + } + + /** + * Fetches both Hackatime admin lookups needed to evaluate ownership. + * Returns `{ ok: false }` on transient API errors so callers can decide + * whether to fail open (the live check does) or skip (the recovery cron does). + * A 404 from `get_user_by_email` is a definitive result, not an error — it + * means no Hackatime user has that email as their primary. + */ + private async fetchOwnershipLookups( + email: string, + storedId: string, + hcaSub: string, + ): Promise { + let emailOwnerId: string | null = null; + try { + const res = await fetchWithTimeout( + `${this.baseUrl}/api/admin/v1/user/get_user_by_email`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${this.adminApiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email }), + }, + ); + if (res.ok) { + const body = await res.json().catch(() => null); + const rawId = body?.user_id ?? body?.data?.user_id ?? null; + if (rawId !== null && rawId !== undefined) { + emailOwnerId = String(rawId); + } + } else if (res.status !== 404) { + return { ok: false, reason: `get_user_by_email returned ${res.status}` }; + } + } catch (err) { + return { ok: false, reason: `get_user_by_email network error: ${err}` }; + } + + let linkedTrustLevel: string | null = null; + let linkedBanned = false; + let linkedEmails: string[] = []; + try { + const res = await fetchWithTimeout( + `${this.baseUrl}/api/admin/v1/user/info?user_id=${encodeURIComponent(storedId)}`, + { headers: { Authorization: `Bearer ${this.adminApiKey}` } }, + ); + if (res.ok) { + const body = await res.json().catch(() => null); + const u = body?.user ?? body?.data ?? body ?? {}; + linkedTrustLevel = + u?.trust_level ?? u?.trust_factor?.trust_level ?? null; + linkedBanned = u?.banned === true; + const rawEmails = u?.email_addresses ?? u?.emails ?? []; + if (Array.isArray(rawEmails)) { + linkedEmails = rawEmails + .filter((e): e is string => typeof e === 'string') + .map((e) => e.toLowerCase()); + } + } + } catch (err) { + this.logger.warn( + `Hackatime ownership check: /user/info error for ${hcaSub}: ${err}`, + ); + } + + return { ok: true, emailOwnerId, linkedTrustLevel, linkedBanned, linkedEmails }; + } + + /** + * Pure decision function. Maps the Hackatime lookups to one of: + * - `pass`: positive proof of ownership found + * - `ban`: the linked Hackatime account is itself banned / red-trust + * (genuine ban-evasion — connecting a banned account's heartbeats) + * - `inconclusive`: no positive proof and no active ban — caller fails open + * + * Owning a second/alternate Hackatime account is NOT itself bannable. A bare + * email/ID mismatch (the connected account isn't the one whose primary email + * is the user's, or doesn't list the user's email) is therefore NOT a ban — + * it only fails to provide positive proof, so it resolves to `inconclusive` + * and the caller lets it through. The ONLY ban trigger is the linked account + * actually being banned/red-trust on Hackatime. + * + * The two positive-proof paths matter: + * 1. `get_user_by_email(email)` returns the stored id (direct match) + * 2. The linked account's email list contains the user's email — necessary + * because `get_user_by_email` only matches primary emails, so users + * whose Beest email is a secondary on Hackatime would otherwise look + * mismatched. + */ + private evaluateOwnership( + email: string, + storedId: string, + lookups: Extract, + ): OwnershipVerdict { + const ownEmail = email.toLowerCase(); + const { emailOwnerId, linkedEmails, linkedBanned, linkedTrustLevel } = lookups; + + // The only bannable condition: the linked Hackatime account is itself + // banned / red-trust. Connecting a banned account's heartbeats is the + // ban-evasion pattern we care about. A mere mismatch (alt account that is + // not banned) is allowed. + const trustBad = linkedTrustLevel === 'red' || linkedBanned; + if (trustBad) { + return { + kind: 'ban', + reason: `Linked Hackatime account is banned (trust=${linkedTrustLevel}, banned=${linkedBanned})`, + }; + } + + const idsMatchByLookup = + emailOwnerId !== null && emailOwnerId === storedId; + const idsMatchByLinkedEmails = linkedEmails.includes(ownEmail); + if (idsMatchByLookup || idsMatchByLinkedEmails) { + return { + kind: 'pass', + reason: idsMatchByLookup + ? 'by-email lookup confirms stored id' + : 'linked account lists user email', + }; + } + + // No positive proof, but no banned linked account either — e.g. the user + // connected a different (non-banned) Hackatime account. Not a ban; fail open. + return { kind: 'inconclusive' }; + } + + /** + * Daily sweep: re-checks every user whose `hackatime_ownership_failed` + * audit log indicates the linked Hackatime account was banned or red-trust + * at ban-time. If Hackatime has since cleared the account AND the rest of + * the ownership check now passes, clear their Airtable `Perms` so they can + * sign back in. + * + * Scope: the only ban reason is a banned / red-trust linked account, which + * is exactly what can recover on Hackatime's side, so the poller picks it up + * via the `banned=true` / `trust=red` labels. + * + * Gated on NODE_ENV === 'production' so it doesn't fire in dev where the + * .env may point at prod Airtable. + */ + @Cron('0 4 * * *', { name: 'hackatime-ownership-recovery' }) + async dailyOwnershipRecoverySweep(): Promise { + if (process.env.NODE_ENV !== 'production') { + this.logger.log('Skipping hackatime-ownership-recovery cron (NODE_ENV != production)'); + return; + } + if (!this.adminApiKey) { + this.logger.warn('Skipping hackatime-ownership-recovery cron — admin key not set'); + return; + } + + this.logger.log('hackatime-ownership-recovery cron starting'); + const candidateUserIds = + await this.auditLogService.findUsersWithOwnershipFailLabels([ + 'banned=true', + 'trust=red', + ]); + this.logger.log( + `hackatime-ownership-recovery: ${candidateUserIds.length} candidate(s)`, + ); + + let reverted = 0; + let stillBad = 0; + let notBanned = 0; + let skipped = 0; + + for (const userId of candidateUserIds) { + const user = await this.userRepo.findOne({ where: { id: userId } }); + if (!user || !user.email || !user.hackatimeUserId) { + skipped += 1; + continue; + } + + let currentPerms: string | null; + try { + currentPerms = await this.rsvpService.getPerms(user.email); + } catch (err) { + this.logger.warn( + `hackatime-ownership-recovery: getPerms failed for ${user.id}: ${err}`, + ); + skipped += 1; + continue; + } + if (currentPerms !== 'Banned') { + notBanned += 1; + continue; + } + + const storedId = String(user.hackatimeUserId); + const lookups = await this.fetchOwnershipLookups( + user.email, + storedId, + user.hcaSub, + ); + if (!lookups.ok) { + skipped += 1; + continue; + } + + const verdict = this.evaluateOwnership(user.email, storedId, lookups); + if (verdict.kind !== 'pass') { + stillBad += 1; + continue; + } + + try { + await this.rsvpService.clearPerms(user.email); + await this.auditLogService.log( + user.id, + 'ban_reverted', + `Hackatime account recovered: ${verdict.reason}`, + ); + reverted += 1; + this.logger.log( + `hackatime-ownership-recovery: reverted ban for ${user.id} (${verdict.reason})`, + ); + } catch (err) { + this.logger.error( + `hackatime-ownership-recovery: revert failed for ${user.id}: ${err}`, + ); + skipped += 1; + } + } + + this.logger.log( + `hackatime-ownership-recovery done: reverted=${reverted} stillBad=${stillBad} notBanned=${notBanned} skipped=${skipped}`, + ); + } + + /** + * Fetches the authenticated user's Hackatime project names. + * Returns only the project name strings — no other data is exposed. + */ + async getProjectNames(userId: string): Promise { + const user = await this.userRepo.findOne({ + where: { hcaSub: userId }, + select: ['hackatimeToken'], + }); + + if (!user?.hackatimeToken) { + this.logger.warn(`No hackatime token found for user ${userId} (user found: ${!!user})`); + return []; + } + + try { + const res = await fetchWithTimeout( + `${this.baseUrl}/api/v1/authenticated/projects`, + { + headers: { Authorization: `Bearer ${user.hackatimeToken}` }, + }, + ); + + if (!res.ok) { + this.logger.warn( + `Hackatime projects fetch failed (${res.status}) for user ${userId}`, + ); + return []; + } + + const data = await res.json(); + const projects: { name: string }[] = data?.projects ?? data?.data ?? []; + + if (!Array.isArray(projects)) return []; + + return projects + .map((p) => (typeof p === 'string' ? p : p?.name)) + .filter((n): n is string => typeof n === 'string' && n.length > 0); + } catch (err) { + this.logger.error(`Hackatime projects fetch error for ${userId}: ${err}`); + return []; + } + } + + /** + * Fetches all-time stats from Hackatime and returns total hours + * plus a per-project-name breakdown for the specified project names. + * Single API call — no duplication. + */ + async getHoursForProjects( + userId: string, + projectNames: string[], + ): Promise<{ hours: number; perProject: Record }> { + if (projectNames.length === 0) { + return { hours: 0, perProject: {} }; + } + + const user = await this.userRepo.findOne({ + where: { hcaSub: userId }, + select: ['hackatimeToken'], + }); + + if (!user?.hackatimeToken) { + return { hours: 0, perProject: {} }; + } + + try { + const res = await fetchWithTimeout( + `${this.baseUrl}/api/v1/authenticated/projects`, + { + headers: { Authorization: `Bearer ${user.hackatimeToken}` }, + }, + ); + + if (!res.ok) { + this.logger.warn( + `Hackatime stats fetch failed (${res.status}) for user ${userId}`, + ); + return { hours: 0, perProject: {} }; + } + + const body = await res.json().catch(() => null); + const projects: { name: string; total_seconds: number }[] = + body?.projects ?? body?.data ?? []; + + if (!Array.isArray(projects)) { + return { hours: 0, perProject: {} }; + } + + const nameSet = new Set(projectNames); + let totalSeconds = 0; + const perProject: Record = {}; + + for (const p of projects) { + if (nameSet.has(p.name)) { + const secs = p.total_seconds ?? 0; + totalSeconds += secs; + perProject[p.name] = Math.round((secs / 3600) * 10) / 10; + } + } + + return { + hours: Math.round((totalSeconds / 3600) * 10) / 10, + perProject, + }; + } catch (err) { + this.logger.error(`Hackatime stats fetch error for ${userId}: ${err}`); + return { hours: 0, perProject: {} }; + } + } + + /** + * One-time backfill: for users who connected Hackatime before we started + * storing the Hackatime user ID, fetch it via their stored OAuth token. + */ + async onModuleInit() { + const needsBackfill = await this.userRepo.find({ + where: { hackatimeUserId: IsNull() }, + select: ['id', 'hcaSub', 'hackatimeToken', 'hackatimeUserId'], + }).then((users) => users.filter((u) => !!u.hackatimeToken)); + if (needsBackfill.length === 0) return; + + this.logger.log(`Backfilling Hackatime user IDs for ${needsBackfill.length} user(s)...`); + + for (const user of needsBackfill) { + try { + const res = await fetchWithTimeout( + `${this.baseUrl}/api/v1/authenticated/me`, + { headers: { Authorization: `Bearer ${user.hackatimeToken}` } }, + ); + if (!res.ok) { + this.logger.warn(`Backfill: /me failed (${res.status}) for user ${user.hcaSub}`); + continue; + } + const raw = await res.json(); + const data = raw?.data ?? raw; + const htId = data?.id?.toString() ?? data?.user_id?.toString() ?? null; + if (htId) { + user.hackatimeUserId = htId; + await this.userRepo.save(user); + this.logger.log(`Backfill: stored Hackatime user ID ${htId} for user ${user.hcaSub}`); + } + } catch (err) { + this.logger.warn(`Backfill: error for user ${user.hcaSub}: ${err}`); + } + } + } +} diff --git a/backend/src/hca/hca.module.ts b/backend/src/hca/hca.module.ts new file mode 100644 index 0000000..58c3262 --- /dev/null +++ b/backend/src/hca/hca.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { User } from '../entities/user.entity'; +import { HcaService } from './hca.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([User])], + providers: [HcaService], + exports: [HcaService], +}) +export class HcaModule {} diff --git a/backend/src/hca/hca.service.ts b/backend/src/hca/hca.service.ts new file mode 100644 index 0000000..3782035 --- /dev/null +++ b/backend/src/hca/hca.service.ts @@ -0,0 +1,122 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { fetchWithTimeout } from '../fetch.util'; +import { User } from '../entities/user.entity'; + +export interface HcaAddress { + street_address?: string; + locality?: string; + region?: string; + postal_code?: string; + country?: string; +} + +export interface HcaIdentity { + sub?: string; + email?: string; + name?: string; + birthdate?: string; + address?: HcaAddress; + [key: string]: unknown; +} + +@Injectable() +export class HcaService { + private readonly logger = new Logger(HcaService.name); + private readonly clientId: string; + private readonly clientSecret: string; + private readonly baseUrl: string; + + constructor( + private readonly config: ConfigService, + @InjectRepository(User) private readonly userRepo: Repository, + ) { + this.clientId = this.config.getOrThrow('CLIENT_ID'); + this.clientSecret = this.config.getOrThrow('CLIENT_SECRET'); + this.baseUrl = this.config.get('HCA_BASE_URL', 'https://auth.hackclub.com'); + } + + async getIdentity(hcaSub: string): Promise { + const user = await this.userRepo.findOne({ + where: { hcaSub }, + select: ['id', 'hcaAccessToken', 'hcaRefreshToken'], + }); + + if (!user?.hcaAccessToken) { + this.logger.warn(`No HCA tokens stored for ${hcaSub} — user needs to re-login`); + return null; + } + + // Try the stored access token first + let identity = await this.fetchUserinfo(user.hcaAccessToken); + if (identity) return identity; + + // Access token expired — try refreshing + if (!user.hcaRefreshToken) { + this.logger.warn(`HCA access token expired and no refresh token for ${hcaSub}`); + return null; + } + + const refreshed = await this.refreshTokens(user.hcaRefreshToken); + if (!refreshed) { + this.logger.warn(`HCA token refresh failed for ${hcaSub} — user needs to re-login`); + return null; + } + + // Persist rotated tokens + user.hcaAccessToken = refreshed.accessToken; + user.hcaRefreshToken = refreshed.refreshToken; + await this.userRepo.save(user); + + return this.fetchUserinfo(refreshed.accessToken); + } + + private async fetchUserinfo(accessToken: string): Promise { + try { + const res = await fetchWithTimeout(`${this.baseUrl}/oauth/userinfo`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (res.status === 401) return null; + if (!res.ok) { + this.logger.warn(`HCA userinfo request failed: ${res.status}`); + return null; + } + return (await res.json()) as HcaIdentity; + } catch (err) { + this.logger.error(`HCA userinfo fetch error: ${err}`); + return null; + } + } + + private async refreshTokens( + refreshToken: string, + ): Promise<{ accessToken: string; refreshToken: string } | null> { + try { + const res = await fetchWithTimeout(`${this.baseUrl}/oauth/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + client_id: this.clientId, + client_secret: this.clientSecret, + refresh_token: refreshToken, + }), + }); + if (!res.ok) { + this.logger.warn(`HCA token refresh failed: ${res.status}`); + return null; + } + const data = await res.json(); + if (!data?.access_token) return null; + return { + accessToken: data.access_token, + refreshToken: data.refresh_token ?? refreshToken, + }; + } catch (err) { + this.logger.error(`HCA token refresh error: ${err}`); + return null; + } + } +} diff --git a/backend/src/hcb/hcb.controller.ts b/backend/src/hcb/hcb.controller.ts new file mode 100644 index 0000000..d68a5d1 --- /dev/null +++ b/backend/src/hcb/hcb.controller.ts @@ -0,0 +1,101 @@ +import { + Controller, + Get, + Post, + Body, + Param, + ParseUUIDPipe, + Req, + UseGuards, + BadRequestException, +} from '@nestjs/common'; +import { Throttle } from '@nestjs/throttler'; +import type { Request } from 'express'; +import { SuperAdminGuard } from '../admin/super-admin.guard'; +import { HcbService, type GrantAdmin } from './hcb.service'; + +@Controller('api/admin/hcb') +@UseGuards(SuperAdminGuard) +export class HcbController { + constructor(private readonly hcbService: HcbService) {} + + private admin(req: Request): GrantAdmin { + const user = (req as any).user; + const uid = user?.uid as string | undefined; + const email = user?.email as string | undefined; + if (!uid || !email) throw new BadRequestException('Not authenticated'); + return { uid, email }; + } + + @Get('status') + status() { + return this.hcbService.getStatus(); + } + + /** Begins the OAuth connect flow — returns the authorize URL + state for the frontend to cookie. */ + @Throttle({ default: { limit: 10, ttl: 60000 } }) + @Post('connect') + connect() { + return this.hcbService.getAuthorizeUrl(); + } + + @Throttle({ default: { limit: 10, ttl: 60000 } }) + @Post('handle-callback') + async handleCallback( + @Req() req: Request, + @Body() body: { code?: string; state?: string; storedState?: string }, + ) { + if (!body.code) throw new BadRequestException('Authorization code is required'); + if (!body.state || !body.storedState) { + throw new BadRequestException('State parameters are required'); + } + return this.hcbService.handleCallback(body.code, body.state, body.storedState, this.admin(req)); + } + + @Get('prefill/:orderId') + prefill(@Param('orderId', ParseUUIDPipe) orderId: string) { + return this.hcbService.buildPrefill(orderId); + } + + @Throttle({ default: { limit: 20, ttl: 60000 } }) + @Post('card-grant') + async createCardGrant( + @Req() req: Request, + @Body() + body: { + orderId?: string; + amountCents?: number; + email?: string; + purpose?: string | null; + merchantLock?: string | null; + categoryLock?: string | null; + keywordLock?: string | null; + oneTimeUse?: boolean; + preAuthorizationRequired?: boolean; + }, + ) { + if (!body.orderId || typeof body.orderId !== 'string') { + throw new BadRequestException('orderId is required'); + } + if (typeof body.amountCents !== 'number') { + throw new BadRequestException('amountCents is required'); + } + if (!body.email || typeof body.email !== 'string') { + throw new BadRequestException('email is required'); + } + return this.hcbService.createCardGrantForOrder( + body.orderId, + { + amountCents: body.amountCents, + email: body.email, + purpose: body.purpose ?? null, + merchantLock: body.merchantLock ?? null, + categoryLock: body.categoryLock ?? null, + keywordLock: body.keywordLock ?? null, + oneTimeUse: body.oneTimeUse, + preAuthorizationRequired: body.preAuthorizationRequired, + }, + this.admin(req), + ); + } +} diff --git a/backend/src/hcb/hcb.module.ts b/backend/src/hcb/hcb.module.ts new file mode 100644 index 0000000..ed19300 --- /dev/null +++ b/backend/src/hcb/hcb.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthModule } from '../auth/auth.module'; +import { RsvpModule } from '../rsvp/rsvp.module'; +import { AuditLogModule } from '../audit-log/audit-log.module'; +import { HcbCredential } from '../entities/hcb-credential.entity'; +import { Order } from '../entities/order.entity'; +import { HcbService } from './hcb.service'; +import { HcbController } from './hcb.controller'; +import { SuperAdminGuard } from '../admin/super-admin.guard'; + +@Module({ + imports: [ + AuthModule, + RsvpModule, + AuditLogModule, + TypeOrmModule.forFeature([HcbCredential, Order]), + ], + controllers: [HcbController], + providers: [HcbService, SuperAdminGuard], + exports: [HcbService], +}) +export class HcbModule {} diff --git a/backend/src/hcb/hcb.service.ts b/backend/src/hcb/hcb.service.ts new file mode 100644 index 0000000..e0654a9 --- /dev/null +++ b/backend/src/hcb/hcb.service.ts @@ -0,0 +1,449 @@ +import { + Injectable, + Logger, + BadRequestException, + ConflictException, + NotFoundException, + ServiceUnavailableException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { createHmac, randomUUID, timingSafeEqual } from 'crypto'; +import { fetchWithTimeout } from '../fetch.util'; +import { HcbCredential } from '../entities/hcb-credential.entity'; +import { Order } from '../entities/order.entity'; +import { User } from '../entities/user.entity'; +import { AuditLogService } from '../audit-log/audit-log.service'; + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +// Refresh proactively when the access token has under this long to live. +const EXPIRY_SKEW_MS = 60 * 1000; + +export type GrantAdmin = { uid: string; email: string }; + +export type CardGrantInput = { + amountCents: number; + email: string; + purpose?: string | null; + merchantLock?: string | null; + categoryLock?: string | null; + keywordLock?: string | null; + // Both default ON when omitted. One-time-use locks the grant to a single + // transaction; pre-authorization requires the recipient to be approved + // before the card activates. + oneTimeUse?: boolean; + preAuthorizationRequired?: boolean; +}; + +export type HcbStatus = { + configured: boolean; + connected: boolean; + orgId: string | null; + connectedByEmail: string | null; + connectedAt: string | null; + expiresAt: string | null; +}; + +export type CardGrantPrefill = { + recipientEmail: string; + // Suggested amount (pipes × rate). Purely a default — the admin may override + // to any amount; there is no server-side cap. + suggestedAmountCents: number | null; + purpose: string; + orgId: string; + alreadyGranted: boolean; + existingGrantId: string | null; +}; + +@Injectable() +export class HcbService { + private readonly logger = new Logger(HcbService.name); + + private readonly baseUrl: string; + private readonly clientId: string | undefined; + private readonly clientSecret: string | undefined; + private readonly redirectUri: string; + private readonly orgId: string | undefined; + private readonly jwtSecret: string; + // Used only to compute the SUGGESTED grant amount (pipes × rate) for the + // prefill. It is not a cap — admins may override the amount freely. + private readonly centsPerPipe: number | undefined; + + private readonly scope = 'read write'; + + constructor( + private readonly config: ConfigService, + @InjectRepository(HcbCredential) + private readonly credRepo: Repository, + @InjectRepository(Order) + private readonly orderRepo: Repository, + private readonly auditLogService: AuditLogService, + ) { + this.baseUrl = (this.config.get('HCB_BASE_URL') ?? 'https://hcb.hackclub.com').replace(/\/$/, ''); + this.clientId = this.config.get('HCB_CLIENT_ID')?.trim() || undefined; + this.clientSecret = this.config.get('HCB_CLIENT_SECRET')?.trim() || undefined; + this.redirectUri = this.config.get('HCB_REDIRECT_URI', 'http://localhost:5173/oauth/hcb/callback'); + this.orgId = this.config.get('HCB_ORG_ID')?.trim() || undefined; + this.jwtSecret = this.config.getOrThrow('JWT_SECRET'); + // Cents per pipe, e.g. 500 = $5 per pipe. Drives the suggested amount only. + this.centsPerPipe = this.parsePositiveInt( + this.config.get('HCB_CENTS_PER_PIPE') ?? this.config.get('HCB_PIPES_TO_CENTS'), + ); + + if (!this.isConfigured) { + this.logger.warn('HCB card grants disabled — set HCB_CLIENT_ID, HCB_CLIENT_SECRET and HCB_ORG_ID'); + } + } + + private parsePositiveInt(raw: string | undefined): number | undefined { + if (raw === undefined) return undefined; + const n = Number(raw.trim()); + if (!Number.isInteger(n) || n <= 0) return undefined; + return n; + } + + private get isConfigured(): boolean { + return !!(this.clientId && this.clientSecret && this.orgId); + } + + // ── OAuth: connect ── + + private signState(state: string): string { + return createHmac('sha256', this.jwtSecret).update(`hcb:${state}`).digest('hex'); + } + + /** + * Builds the HCB authorize URL and a signed state value. The caller must + * store `state` in an httpOnly cookie and pass it back on the callback. + */ + getAuthorizeUrl(): { url: string; state: string } { + if (!this.isConfigured) { + throw new ServiceUnavailableException('HCB is not configured'); + } + const state = randomUUID(); + const signedState = `${state}.${this.signState(state)}`; + const params = new URLSearchParams({ + client_id: this.clientId!, + redirect_uri: this.redirectUri, + response_type: 'code', + scope: this.scope, + state: signedState, + }); + // HCB mounts Doorkeeper under /api/v4/oauth (not the conventional /oauth). + return { url: `${this.baseUrl}/api/v4/oauth/authorize?${params.toString()}`, state }; + } + + /** + * Verifies the OAuth state (CSRF), exchanges the code for tokens, and stores + * them encrypted. Throws on any verification or exchange failure. + */ + async handleCallback( + code: string, + returnedSignedState: string, + cookieState: string, + admin: GrantAdmin, + ): Promise { + if (!this.isConfigured) { + throw new ServiceUnavailableException('HCB is not configured'); + } + this.verifyState(returnedSignedState, cookieState); + + const tokenRes = await fetchWithTimeout(`${this.baseUrl}/api/v4/oauth/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: this.redirectUri, + client_id: this.clientId!, + client_secret: this.clientSecret!, + }), + }); + + if (!tokenRes.ok) { + this.logger.error(`HCB token exchange failed: ${tokenRes.status}`); + throw new BadRequestException('HCB authorization failed'); + } + + const tokens = await tokenRes.json().catch(() => null); + await this.persistTokens(tokens, admin); + return this.getStatus(); + } + + private verifyState(returnedSignedState: string, cookieState: string): void { + const dot = returnedSignedState.lastIndexOf('.'); + if (dot === -1) throw new BadRequestException('Malformed state'); + const value = returnedSignedState.slice(0, dot); + const sig = returnedSignedState.slice(dot + 1); + + if (!this.safeEqual(value, cookieState)) { + throw new BadRequestException('State mismatch'); + } + if (!this.safeEqual(sig, this.signState(value))) { + throw new BadRequestException('Invalid state signature'); + } + } + + private safeEqual(a: string, b: string): boolean { + const ab = Buffer.from(a); + const bb = Buffer.from(b); + return ab.length === bb.length && timingSafeEqual(ab, bb); + } + + private async persistTokens(tokens: any, admin: GrantAdmin | null): Promise { + const accessToken = typeof tokens?.access_token === 'string' ? tokens.access_token : null; + const refreshToken = typeof tokens?.refresh_token === 'string' ? tokens.refresh_token : null; + if (!accessToken || !refreshToken) { + throw new BadRequestException('Invalid token response from HCB'); + } + const expiresInSec = Number(tokens?.expires_in); + const ttlMs = Number.isFinite(expiresInSec) && expiresInSec > 0 ? expiresInSec * 1000 : 2 * 60 * 60 * 1000; + const expiresAt = new Date(Date.now() + ttlMs); + + const existing = await this.credRepo.findOne({ where: { id: HcbCredential.SINGLETON_ID } }); + const cred = existing ?? this.credRepo.create({ id: HcbCredential.SINGLETON_ID }); + cred.accessToken = accessToken; + cred.refreshToken = refreshToken; + cred.expiresAt = expiresAt; + cred.scope = typeof tokens?.scope === 'string' ? tokens.scope : this.scope; + if (admin) { + cred.connectedByUserId = admin.uid; + cred.connectedByEmail = admin.email; + } + await this.credRepo.save(cred); + } + + // ── OAuth: token use / refresh ── + + /** Returns a non-expired access token, refreshing via the refresh token if needed. */ + private async getValidAccessToken(): Promise { + const cred = await this.credRepo.findOne({ where: { id: HcbCredential.SINGLETON_ID } }); + if (!cred) { + throw new ServiceUnavailableException('HCB is not connected. A super admin must connect it first.'); + } + if (cred.expiresAt.getTime() - EXPIRY_SKEW_MS > Date.now()) { + return cred.accessToken; + } + return this.refresh(cred); + } + + private async refresh(cred: HcbCredential): Promise { + const res = await fetchWithTimeout(`${this.baseUrl}/api/v4/oauth/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: cred.refreshToken, + client_id: this.clientId!, + client_secret: this.clientSecret!, + }), + }); + if (!res.ok) { + this.logger.error(`HCB token refresh failed: ${res.status}`); + throw new ServiceUnavailableException('HCB connection expired. A super admin must reconnect it.'); + } + const tokens = await res.json().catch(() => null); + await this.persistTokens(tokens, null); + const updated = await this.credRepo.findOne({ where: { id: HcbCredential.SINGLETON_ID } }); + return updated!.accessToken; + } + + // ── Status ── + + async getStatus(): Promise { + const cred = await this.credRepo.findOne({ where: { id: HcbCredential.SINGLETON_ID } }); + return { + configured: this.isConfigured, + connected: !!cred, + orgId: this.orgId ?? null, + connectedByEmail: cred?.connectedByEmail ?? null, + connectedAt: cred?.createdAt?.toISOString() ?? null, + expiresAt: cred?.expiresAt?.toISOString() ?? null, + }; + } + + // ── Card grants ── + + /** Prefill values for the grant popup. The suggested amount is just a default. */ + async buildPrefill(orderId: string): Promise { + if (!this.orgId) throw new ServiceUnavailableException('HCB org is not configured'); + const order = await this.orderRepo.findOne({ where: { id: orderId }, relations: ['user'] }); + if (!order) throw new NotFoundException('Order not found'); + + const suggested = + this.centsPerPipe !== undefined ? order.pipesSpent * this.centsPerPipe : null; + + return { + recipientEmail: order.user?.email ?? '', + suggestedAmountCents: suggested, + purpose: this.defaultPurpose(order.itemName), + orgId: this.orgId, + alreadyGranted: !!order.hcbCardGrantId, + existingGrantId: order.hcbCardGrantId, + }; + } + + private defaultPurpose(itemName: string): string { + return (itemName ?? 'Grant').slice(0, 30); + } + + /** + * Creates an HCB card grant for an order. + * + * Money-safety: + * - The order row is locked FOR UPDATE and the grant id is stamped onto it in + * the same transaction; a unique index makes a duplicate grant impossible. + * - The audit log is written AFTER commit, so a failed audit insert can never + * roll back a grant whose money has already moved. + * - If the order update fails *after* HCB created the grant, the grant id is + * logged at error level for manual reconciliation. (Residual: HCB exposes no + * idempotency key, so a retry before reconciliation could double-issue.) + */ + async createCardGrantForOrder( + orderId: string, + input: CardGrantInput, + admin: GrantAdmin, + ): Promise<{ grantId: string; amountCents: number; status: string }> { + if (!this.isConfigured || !this.orgId) { + throw new ServiceUnavailableException('HCB is not configured'); + } + + // Validate email + purpose up front (cheap, no lock held). + const email = (input.email ?? '').trim().toLowerCase(); + if (!EMAIL_RE.test(email)) { + throw new BadRequestException('A valid recipient email is required'); + } + const purpose = typeof input.purpose === 'string' ? input.purpose.trim().slice(0, 30) : undefined; + const merchantLock = this.cleanLock(input.merchantLock); + const categoryLock = this.cleanLock(input.categoryLock); + const keywordLock = this.cleanLock(input.keywordLock); + // Default both protections ON; only an explicit `false` disables them. + const oneTimeUse = input.oneTimeUse !== false; + const preAuthorizationRequired = input.preAuthorizationRequired !== false; + + const amountCents = input.amountCents; + if (!Number.isInteger(amountCents) || amountCents <= 0) { + throw new BadRequestException('Amount must be a positive whole number of cents'); + } + + const accessToken = await this.getValidAccessToken(); + + // Set once HCB confirms the grant (real money moved). If the transaction + // then fails to commit, we log this id so the grant can be reconciled + // before any retry — preventing a silent double-issue. + let issuedGrantId: string | null = null; + + let result: { + grantId: string; + amountCents: number; + status: string; + recipientUserId: string; + }; + + try { + result = await this.orderRepo.manager.transaction(async (em) => { + // Lock the order row alone (no joins — Postgres FOR UPDATE can't span outer joins). + const order = await em.findOne(Order, { + where: { id: orderId }, + lock: { mode: 'pessimistic_write' }, + }); + if (!order) throw new NotFoundException('Order not found'); + if (order.hcbCardGrantId) { + throw new ConflictException(`A card grant (${order.hcbCardGrantId}) was already issued for this order`); + } + + const body: Record = { + amount_cents: amountCents, + email, + one_time_use: oneTimeUse, + pre_authorization_required: preAuthorizationRequired, + }; + if (purpose) body.purpose = purpose; + if (merchantLock) body.merchant_lock = merchantLock; + if (categoryLock) body.category_lock = categoryLock; + if (keywordLock) body.keyword_lock = keywordLock; + + const res = await fetchWithTimeout( + `${this.baseUrl}/api/v4/organizations/${encodeURIComponent(this.orgId!)}/card_grants`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(body), + }, + ); + + if (!res.ok) { + // Non-2xx → no money moved; safe to throw and roll back. + const detail = await res.text().catch(() => ''); + this.logger.error(`HCB card grant failed: ${res.status} ${detail.slice(0, 300)}`); + if (res.status === 400) { + throw new BadRequestException('HCB rejected the grant (check amount, email, and locks)'); + } + if (res.status === 401 || res.status === 403) { + throw new ServiceUnavailableException('HCB authorization is no longer valid. A super admin must reconnect.'); + } + throw new ServiceUnavailableException('HCB card grant request failed'); + } + + const grant = await res.json().catch(() => null); + const grantId = typeof grant?.id === 'string' ? grant.id : null; + if (!grantId) { + // Money may have moved but we couldn't read the id — surface loudly. + this.logger.error('HCB card grant succeeded but response had no id'); + throw new ServiceUnavailableException('HCB returned an unexpected response; verify in HCB before retrying'); + } + + // Past this point real money has moved. + issuedGrantId = grantId; + + order.hcbCardGrantId = grantId; + await em.save(order); + + return { + grantId, + amountCents, + status: typeof grant?.status === 'string' ? grant.status : 'active', + recipientUserId: order.userId, + }; + }); + } catch (err) { + if (issuedGrantId) { + // The grant exists at HCB but the order update/commit failed. Do NOT + // retry blindly — reconcile in HCB first. + this.logger.error( + `CRITICAL: HCB grant ${issuedGrantId} (${amountCents}c to ${email}) was created but order ${orderId} ` + + `could not be updated — money has moved. Reconcile in HCB before any retry. ` + + `Cause: ${err instanceof Error ? err.message : String(err)}`, + ); + } + throw err; + } + + // Audit AFTER commit: a failed audit insert must never roll back a grant + // whose money has already moved. + try { + await this.auditLogService.log( + result.recipientUserId, + 'card_grant_issued', + `Card grant ${result.grantId} for ${result.amountCents}c issued to ${email} by ${admin.email}`, + ); + } catch (err) { + this.logger.error( + `Audit log failed for grant ${result.grantId} (grant itself succeeded): ${err instanceof Error ? err.message : String(err)}`, + ); + } + + return { grantId: result.grantId, amountCents: result.amountCents, status: result.status }; + } + + private cleanLock(value: string | null | undefined): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed.length ? trimmed : undefined; + } +} diff --git a/backend/src/health.controller.ts b/backend/src/health.controller.ts new file mode 100644 index 0000000..3b6fb40 --- /dev/null +++ b/backend/src/health.controller.ts @@ -0,0 +1,9 @@ +import { Controller, Get } from '@nestjs/common'; + +@Controller('api/health') +export class HealthController { + @Get() + check() { + return { status: 'ok' }; + } +} diff --git a/backend/src/identity/identity.module.ts b/backend/src/identity/identity.module.ts new file mode 100644 index 0000000..6dba188 --- /dev/null +++ b/backend/src/identity/identity.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { IdentityService } from './identity.service'; + +@Module({ + providers: [IdentityService], + exports: [IdentityService], +}) +export class IdentityModule {} diff --git a/backend/src/identity/identity.service.ts b/backend/src/identity/identity.service.ts new file mode 100644 index 0000000..7cd5b68 --- /dev/null +++ b/backend/src/identity/identity.service.ts @@ -0,0 +1,50 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { fetchWithTimeout } from '../fetch.util'; + +const IDENTITY_CHECK_URL = 'https://identity.hackclub.com/api/external/check'; + +/** + * Live check against identity.hackclub.com — no auth required, no caching. + * Lets us see verification status changes immediately, without waiting for + * the user to log out and back in to refresh their JWT. + * + * The endpoint returns { result: string }; values starting with "verified" + * (e.g. "verified_eligible") count as verified. + */ +@Injectable() +export class IdentityService { + private readonly logger = new Logger(IdentityService.name); + + async isVerified(opts: { slackId?: string | null; email?: string | null }): Promise { + const result = await this.fetchResult(opts); + return typeof result === 'string' && result.startsWith('verified'); + } + + private async fetchResult(opts: { + slackId?: string | null; + email?: string | null; + }): Promise { + if (opts.slackId) { + const r = await this.tryFetch({ slack_id: opts.slackId }); + if (r !== null) return r; + } + if (opts.email) { + return this.tryFetch({ email: opts.email }); + } + return null; + } + + private async tryFetch(params: Record): Promise { + const url = `${IDENTITY_CHECK_URL}?${new URLSearchParams(params).toString()}`; + try { + const res = await fetchWithTimeout(url); + if (!res.ok) return null; + const data = await res.json().catch(() => null); + const result = data?.result; + return typeof result === 'string' ? result : null; + } catch (err) { + this.logger.warn(`Identity check failed for ${Object.keys(params)[0]}: ${err}`); + return null; + } + } +} diff --git a/backend/src/lapse/lapse.controller.ts b/backend/src/lapse/lapse.controller.ts new file mode 100644 index 0000000..60c8476 --- /dev/null +++ b/backend/src/lapse/lapse.controller.ts @@ -0,0 +1,46 @@ +import { + Controller, + Get, + Param, + ParseUUIDPipe, + UseGuards, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ReviewerGuard } from '../admin/reviewer.guard'; +import { Project } from '../entities/project.entity'; +import { LapseService } from './lapse.service'; + +@Controller('api/admin/projects') +export class LapseController { + constructor( + private readonly lapseService: LapseService, + @InjectRepository(Project) private readonly projectRepo: Repository, + ) {} + + /** + * Reviewer-gated lookup of Lapse timelapses for a beest project's owner, + * filtered to the project's linked Hackatime project names. Reviewer never + * sees the program key or the raw Lapse response — only a whitelisted DTO. + * Always returns `{ timelapses: [...] }`; empty array means "show nothing". + */ + @UseGuards(ReviewerGuard) + @Get(':id/lapse') + async getTimelapses(@Param('id', ParseUUIDPipe) id: string) { + const project = await this.projectRepo.findOne({ + where: { id }, + relations: ['user'], + }); + if (!project) throw new NotFoundException('Project not found'); + + const email = project.user?.email ?? null; + const names = project.hackatimeProjectName ?? []; + if (!email || names.length === 0) { + return { timelapses: [] }; + } + + const timelapses = await this.lapseService.findForProject(email, names); + return { timelapses }; + } +} diff --git a/backend/src/lapse/lapse.module.ts b/backend/src/lapse/lapse.module.ts new file mode 100644 index 0000000..454f851 --- /dev/null +++ b/backend/src/lapse/lapse.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthModule } from '../auth/auth.module'; +import { RsvpModule } from '../rsvp/rsvp.module'; +import { Project } from '../entities/project.entity'; +import { LapseService } from './lapse.service'; +import { LapseController } from './lapse.controller'; +import { ReviewerGuard } from '../admin/reviewer.guard'; + +@Module({ + imports: [AuthModule, RsvpModule, TypeOrmModule.forFeature([Project])], + controllers: [LapseController], + providers: [LapseService, ReviewerGuard], + exports: [LapseService], +}) +export class LapseModule {} diff --git a/backend/src/lapse/lapse.service.ts b/backend/src/lapse/lapse.service.ts new file mode 100644 index 0000000..01cff07 --- /dev/null +++ b/backend/src/lapse/lapse.service.ts @@ -0,0 +1,161 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { fetchWithTimeout } from '../fetch.util'; + +export type TimelapseDTO = { + id: string; + name: string; + playbackUrl: string; + thumbnailUrl: string | null; + duration: number | null; + createdAt: number | null; + hackatimeProject: string | null; + visibility: string | null; +}; + +type RawLapseUser = { id?: unknown } | null | undefined; + +type RawTimelapse = { + id?: unknown; + name?: unknown; + playbackUrl?: unknown; + thumbnailUrl?: unknown; + duration?: unknown; + createdAt?: unknown; + visibility?: unknown; + private?: { hackatimeProject?: unknown } | null; + hackatimeProject?: unknown; +}; + +@Injectable() +export class LapseService { + private readonly logger = new Logger(LapseService.name); + private readonly baseUrl: string; + private readonly programKey: string | undefined; + + // 60s cache of (email → lapseUserId|null) so the audit queue's per-row fetch + // doesn't slam /api/user/queryByEmail. Timelapse lists themselves aren't + // cached — playbackUrl may be a rotating signed URL. + private readonly userIdCache = new Map(); + private readonly USER_ID_CACHE_TTL = 60 * 1000; + + constructor(private readonly config: ConfigService) { + this.baseUrl = (this.config.get('LAPSE_BASE_URL') ?? 'https://lapse.hackclub.com').replace(/\/$/, ''); + this.programKey = this.config.get('LAPSE_PROGRAM_KEY')?.trim() || undefined; + if (!this.programKey) { + this.logger.warn('LAPSE_PROGRAM_KEY not set — Lapse integration disabled'); + } + } + + private get configured(): boolean { + return !!this.programKey; + } + + /** + * Returns Lapse timelapses for a beest user (by email) whose + * `hackatimeProject` matches any of the project's linked Hackatime names. + * Returns `[]` for all failure modes (no key, network, 4xx, malformed) so + * a Lapse outage cannot block a review. + */ + async findForProject( + email: string, + hackatimeProjectNames: string[], + ): Promise { + if (!this.configured) return []; + if (!email) return []; + if (!hackatimeProjectNames.length) return []; + + const lapseUserId = await this.queryByEmail(email); + if (!lapseUserId) return []; + + const raw = await this.findByUser(lapseUserId); + if (!raw.length) return []; + + const nameSet = new Set(hackatimeProjectNames.filter((n) => typeof n === 'string' && n.length > 0)); + + const mapped: TimelapseDTO[] = []; + for (const t of raw) { + const id = typeof t.id === 'string' ? t.id : null; + const playbackUrl = typeof t.playbackUrl === 'string' ? t.playbackUrl : null; + if (!id || !playbackUrl) continue; + + // Program-key calls return private fields inlined. Be tolerant of either shape. + const htProject = + (t.private && typeof t.private.hackatimeProject === 'string' ? t.private.hackatimeProject : null) ?? + (typeof t.hackatimeProject === 'string' ? t.hackatimeProject : null); + if (!htProject || !nameSet.has(htProject)) continue; + + mapped.push({ + id, + name: typeof t.name === 'string' ? t.name : '', + playbackUrl, + thumbnailUrl: typeof t.thumbnailUrl === 'string' ? t.thumbnailUrl : null, + duration: typeof t.duration === 'number' && Number.isFinite(t.duration) ? t.duration : null, + createdAt: typeof t.createdAt === 'number' && Number.isFinite(t.createdAt) ? t.createdAt : null, + hackatimeProject: htProject, + visibility: typeof t.visibility === 'string' ? t.visibility : null, + }); + } + + mapped.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0)); + return mapped; + } + + private async queryByEmail(email: string): Promise { + const cached = this.userIdCache.get(email); + const now = Date.now(); + if (cached && cached.expiresAt > now) return cached.id; + + const url = `${this.baseUrl}/api/user/queryByEmail?email=${encodeURIComponent(email)}`; + let id: string | null = null; + try { + const res = await fetchWithTimeout(url, { + headers: { + Authorization: `Bearer ${this.programKey}`, + Accept: 'application/json', + }, + }); + if (res.status === 404) { + // intentional miss — cache the null + } else if (!res.ok) { + this.logger.warn(`Lapse queryByEmail failed: ${res.status}`); + return null; // don't cache transient failures + } else { + const body = await res.json().catch(() => null); + const u: RawLapseUser = body?.data?.user ?? body?.user ?? body?.data ?? null; + const rawId = u?.id; + if (typeof rawId === 'string') id = rawId; + else if (typeof rawId === 'number') id = String(rawId); + } + } catch (err) { + this.logger.warn(`Lapse queryByEmail error: ${err}`); + return null; + } + + this.userIdCache.set(email, { id, expiresAt: now + this.USER_ID_CACHE_TTL }); + return id; + } + + private async findByUser(lapseUserId: string): Promise { + const url = `${this.baseUrl}/api/timelapse/findByUser?user=${encodeURIComponent(lapseUserId)}`; + try { + const res = await fetchWithTimeout(url, { + headers: { + Authorization: `Bearer ${this.programKey}`, + Accept: 'application/json', + }, + }); + if (!res.ok) { + this.logger.warn(`Lapse findByUser failed: ${res.status}`); + return []; + } + const body = await res.json().catch(() => null); + const list = body?.data?.timelapses ?? body?.timelapses ?? body?.data ?? null; + if (!Array.isArray(list)) return []; + return list as RawTimelapse[]; + } catch (err) { + this.logger.warn(`Lapse findByUser error: ${err}`); + return []; + } + } +} diff --git a/backend/src/main.ts b/backend/src/main.ts new file mode 100644 index 0000000..984deac --- /dev/null +++ b/backend/src/main.ts @@ -0,0 +1,26 @@ +import { NestFactory } from '@nestjs/core'; +import { Logger } from '@nestjs/common'; +import { json } from 'express'; +import helmet from 'helmet'; +import { AppModule } from './app.module'; + +process.on('unhandledRejection', (reason) => { + const logger = new Logger('UnhandledRejection'); + logger.fatal('Unhandled promise rejection', reason); + process.exit(1); +}); + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + app.use(json({ limit: '50mb' })); + app.use(helmet()); + + app.enableCors({ + origin: process.env.FRONTEND_URL ?? 'http://localhost:5173', + credentials: true, + }); + + await app.listen(process.env.PORT ?? 3001); +} +bootstrap(); diff --git a/backend/src/migrations/1743552000000-InitialSchema.ts b/backend/src/migrations/1743552000000-InitialSchema.ts new file mode 100644 index 0000000..cdf9893 --- /dev/null +++ b/backend/src/migrations/1743552000000-InitialSchema.ts @@ -0,0 +1,67 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class InitialSchema1743552000000 implements MigrationInterface { + name = 'InitialSchema1743552000000'; + + public async up(queryRunner: QueryRunner): Promise { + // — users — + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "users" ( + "id" uuid DEFAULT gen_random_uuid() NOT NULL, + "hca_sub" varchar NOT NULL, + "email" text NOT NULL, + "name" varchar, + "nickname" varchar, + "slack_id" varchar, + "hackatime_token" text, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_users" PRIMARY KEY ("id"), + CONSTRAINT "UQ_users_hca_sub" UNIQUE ("hca_sub") + ) + `); + + // — sessions — + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "sessions" ( + "id" uuid DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "refresh_token_hash" varchar NOT NULL, + "expires_at" TIMESTAMP NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_sessions" PRIMARY KEY ("id"), + CONSTRAINT "UQ_sessions_refresh_token_hash" UNIQUE ("refresh_token_hash"), + CONSTRAINT "FK_sessions_user" FOREIGN KEY ("user_id") + REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION + ) + `); + + // — projects — + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "projects" ( + "id" uuid DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "name" varchar(50) NOT NULL, + "description" varchar(300) NOT NULL, + "project_type" varchar(20) NOT NULL, + "code_url" varchar(2048), + "readme_url" varchar(2048), + "demo_url" varchar(2048), + "screenshot_1_url" varchar(2048), + "screenshot_2_url" varchar(2048), + "hackatime_project_name" varchar(255), + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_projects" PRIMARY KEY ("id"), + CONSTRAINT "FK_projects_user" FOREIGN KEY ("user_id") + REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION + ) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "projects"`); + await queryRunner.query(`DROP TABLE IF EXISTS "sessions"`); + await queryRunner.query(`DROP TABLE IF EXISTS "users"`); + } +} diff --git a/backend/src/migrations/1743552001000-AddForeignKeyIndexes.ts b/backend/src/migrations/1743552001000-AddForeignKeyIndexes.ts new file mode 100644 index 0000000..eb23a70 --- /dev/null +++ b/backend/src/migrations/1743552001000-AddForeignKeyIndexes.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddForeignKeyIndexes1743552001000 implements MigrationInterface { + name = 'AddForeignKeyIndexes1743552001000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "idx_sessions_user_id" ON "sessions" ("user_id")`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "idx_projects_user_id" ON "projects" ("user_id")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS "idx_projects_user_id"`); + await queryRunner.query(`DROP INDEX IF EXISTS "idx_sessions_user_id"`); + } +} diff --git a/backend/src/migrations/1743552002000-CreateAuditLogs.ts b/backend/src/migrations/1743552002000-CreateAuditLogs.ts new file mode 100644 index 0000000..ed60614 --- /dev/null +++ b/backend/src/migrations/1743552002000-CreateAuditLogs.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateAuditLogs1743552002000 implements MigrationInterface { + name = 'CreateAuditLogs1743552002000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "audit_logs" ( + "id" uuid DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "action" varchar(50) NOT NULL, + "label" varchar(255) NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_audit_logs" PRIMARY KEY ("id"), + CONSTRAINT "FK_audit_logs_user" FOREIGN KEY ("user_id") + REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION + ) + `); + + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "idx_audit_logs_user_id" ON "audit_logs" ("user_id")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "audit_logs"`); + } +} diff --git a/backend/src/migrations/1775167703252-AddProjectFlags.ts b/backend/src/migrations/1775167703252-AddProjectFlags.ts new file mode 100644 index 0000000..aa72ae1 --- /dev/null +++ b/backend/src/migrations/1775167703252-AddProjectFlags.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddProjectFlags1775167703252 implements MigrationInterface { + name = 'AddProjectFlags1775167703252' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "projects" DROP CONSTRAINT "FK_projects_user"`); + await queryRunner.query(`ALTER TABLE "audit_logs" DROP CONSTRAINT "FK_audit_logs_user"`); + await queryRunner.query(`DROP INDEX "public"."idx_sessions_user_id"`); + await queryRunner.query(`DROP INDEX "public"."idx_projects_user_id"`); + await queryRunner.query(`DROP INDEX "public"."idx_audit_logs_user_id"`); + await queryRunner.query(`ALTER TABLE "projects" ADD CONSTRAINT "FK_bd55b203eb9f92b0c8390380010" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "audit_logs" ADD CONSTRAINT "FK_bd2726fd31b35443f2245b93ba0" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "audit_logs" DROP CONSTRAINT "FK_bd2726fd31b35443f2245b93ba0"`); + await queryRunner.query(`ALTER TABLE "projects" DROP CONSTRAINT "FK_bd55b203eb9f92b0c8390380010"`); + await queryRunner.query(`CREATE INDEX "idx_audit_logs_user_id" ON "audit_logs" ("user_id") `); + await queryRunner.query(`CREATE INDEX "idx_projects_user_id" ON "projects" ("user_id") `); + await queryRunner.query(`CREATE INDEX "idx_sessions_user_id" ON "sessions" ("user_id") `); + await queryRunner.query(`ALTER TABLE "audit_logs" ADD CONSTRAINT "FK_audit_logs_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "projects" ADD CONSTRAINT "FK_projects_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + +} diff --git a/backend/src/migrations/1775169229562-AddProjectStatus.ts b/backend/src/migrations/1775169229562-AddProjectStatus.ts new file mode 100644 index 0000000..5702864 --- /dev/null +++ b/backend/src/migrations/1775169229562-AddProjectStatus.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddProjectStatus1775169229562 implements MigrationInterface { + name = 'AddProjectStatus1775169229562' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "projects" ADD "status" character varying(20) NOT NULL DEFAULT 'unshipped'`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "projects" DROP COLUMN "status"`); + } + +} diff --git a/backend/src/migrations/1775200000000-AddTwoEmails.ts b/backend/src/migrations/1775200000000-AddTwoEmails.ts new file mode 100644 index 0000000..df075cf --- /dev/null +++ b/backend/src/migrations/1775200000000-AddTwoEmails.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddTwoEmails1775200000000 implements MigrationInterface { + name = 'AddTwoEmails1775200000000' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" ADD "two_emails" boolean NOT NULL DEFAULT false`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "two_emails"`); + } + +} diff --git a/backend/src/migrations/1775300000000-AddAiUse.ts b/backend/src/migrations/1775300000000-AddAiUse.ts new file mode 100644 index 0000000..5cb6811 --- /dev/null +++ b/backend/src/migrations/1775300000000-AddAiUse.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddAiUse1775300000000 implements MigrationInterface { + name = 'AddAiUse1775300000000' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "projects" ADD COLUMN IF NOT EXISTS "ai_use" varchar(1000)`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "projects" DROP COLUMN "ai_use"`); + } +} diff --git a/backend/src/migrations/1775300001000-ShrinkAiUse.ts b/backend/src/migrations/1775300001000-ShrinkAiUse.ts new file mode 100644 index 0000000..222f403 --- /dev/null +++ b/backend/src/migrations/1775300001000-ShrinkAiUse.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class ShrinkAiUse1775300001000 implements MigrationInterface { + name = 'ShrinkAiUse1775300001000' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "projects" ALTER COLUMN "ai_use" TYPE varchar(200)`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "projects" ALTER COLUMN "ai_use" TYPE varchar(1000)`); + } +} diff --git a/backend/src/migrations/1775400000000-CreateNewsItems.ts b/backend/src/migrations/1775400000000-CreateNewsItems.ts new file mode 100644 index 0000000..00a0d17 --- /dev/null +++ b/backend/src/migrations/1775400000000-CreateNewsItems.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CreateNewsItems1775400000000 implements MigrationInterface { + name = 'CreateNewsItems1775400000000' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "news_items" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "text" varchar(500) NOT NULL, + "display_date" date NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_news_items" PRIMARY KEY ("id") + ) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "news_items"`); + } +} diff --git a/backend/src/migrations/1775500000000-AddHackatimeUserId.ts b/backend/src/migrations/1775500000000-AddHackatimeUserId.ts new file mode 100644 index 0000000..a949eb8 --- /dev/null +++ b/backend/src/migrations/1775500000000-AddHackatimeUserId.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddHackatimeUserId1775500000000 implements MigrationInterface { + name = 'AddHackatimeUserId1775500000000' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "users" ADD "hackatime_user_id" varchar NULL`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "users" DROP COLUMN "hackatime_user_id"`, + ); + } +} diff --git a/backend/src/migrations/1775500001000-CreateProjectReviews.ts b/backend/src/migrations/1775500001000-CreateProjectReviews.ts new file mode 100644 index 0000000..3ae4339 --- /dev/null +++ b/backend/src/migrations/1775500001000-CreateProjectReviews.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CreateProjectReviews1775500001000 implements MigrationInterface { + name = 'CreateProjectReviews1775500001000' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "project_reviews" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "project_id" uuid NOT NULL, + "reviewer_id" uuid NOT NULL, + "status" varchar(20) NOT NULL, + "feedback" text, + "internal_note" text, + "override_justification" text, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_project_reviews" PRIMARY KEY ("id"), + CONSTRAINT "FK_project_reviews_project" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE, + CONSTRAINT "FK_project_reviews_reviewer" FOREIGN KEY ("reviewer_id") REFERENCES "users"("id") ON DELETE CASCADE + ) + `); + await queryRunner.query(`CREATE INDEX "IDX_project_reviews_project_id" ON "project_reviews" ("project_id")`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "project_reviews"`); + } +} diff --git a/backend/src/migrations/1775500002000-AddProjectHoursOverrides.ts b/backend/src/migrations/1775500002000-AddProjectHoursOverrides.ts new file mode 100644 index 0000000..6c12fe1 --- /dev/null +++ b/backend/src/migrations/1775500002000-AddProjectHoursOverrides.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddProjectHoursOverrides1775500002000 implements MigrationInterface { + name = 'AddProjectHoursOverrides1775500002000' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "projects" ADD "override_hours" real`); + await queryRunner.query(`ALTER TABLE "projects" ADD "internal_hours" real`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "projects" DROP COLUMN "internal_hours"`); + await queryRunner.query(`ALTER TABLE "projects" DROP COLUMN "override_hours"`); + } +} diff --git a/backend/src/migrations/1775600000000-CreateComments.ts b/backend/src/migrations/1775600000000-CreateComments.ts new file mode 100644 index 0000000..9364d19 --- /dev/null +++ b/backend/src/migrations/1775600000000-CreateComments.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateComments1775600000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "comments" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "project_id" uuid NOT NULL, + "user_id" uuid NOT NULL, + "body" varchar(500) NOT NULL, + "created_at" timestamp NOT NULL DEFAULT now(), + CONSTRAINT "PK_comments" PRIMARY KEY ("id"), + CONSTRAINT "FK_comments_project" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE, + CONSTRAINT "FK_comments_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE + ) + `); + + await queryRunner.query(`CREATE INDEX "IDX_comments_project_id" ON "comments" ("project_id")`); + await queryRunner.query(`CREATE INDEX "IDX_comments_user_id" ON "comments" ("user_id")`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "comments"`); + } +} diff --git a/backend/src/migrations/1775800000000-CreateShopItems.ts b/backend/src/migrations/1775800000000-CreateShopItems.ts new file mode 100644 index 0000000..29be19b --- /dev/null +++ b/backend/src/migrations/1775800000000-CreateShopItems.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CreateShopItems1775800000000 implements MigrationInterface { + name = 'CreateShopItems1775800000000' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "shop_items" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "name" varchar(200) NOT NULL, + "description" varchar(500) NOT NULL, + "image_url" varchar(500) NOT NULL, + "price_hours" integer NOT NULL, + "stock" integer, + "sort_order" integer NOT NULL DEFAULT 0, + "is_active" boolean NOT NULL DEFAULT true, + "estimated_ship" varchar(200), + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_shop_items" PRIMARY KEY ("id") + ) + `); + await queryRunner.query(`CREATE INDEX "IDX_shop_items_sort_order" ON "shop_items"("sort_order")`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "shop_items"`); + } +} diff --git a/backend/src/migrations/1775900000000-AddShippingEligibility.ts b/backend/src/migrations/1775900000000-AddShippingEligibility.ts new file mode 100644 index 0000000..7f937a4 --- /dev/null +++ b/backend/src/migrations/1775900000000-AddShippingEligibility.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddShippingEligibility1775900000000 implements MigrationInterface { + name = 'AddShippingEligibility1775900000000' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" ADD "has_address" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "users" ADD "has_birthdate" boolean NOT NULL DEFAULT false`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "has_birthdate"`); + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "has_address"`); + } + +} diff --git a/backend/src/migrations/1776000000000-CreateOrdersAndFulfillment.ts b/backend/src/migrations/1776000000000-CreateOrdersAndFulfillment.ts new file mode 100644 index 0000000..835aca1 --- /dev/null +++ b/backend/src/migrations/1776000000000-CreateOrdersAndFulfillment.ts @@ -0,0 +1,58 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CreateOrdersAndFulfillment1776000000000 implements MigrationInterface { + name = 'CreateOrdersAndFulfillment1776000000000' + + public async up(queryRunner: QueryRunner): Promise { + // Add pipes column to users + await queryRunner.query(`ALTER TABLE "users" ADD "pipes" integer NOT NULL DEFAULT 0`); + + // Add pipes_granted to projects (tracks how many pipes were already granted to prevent double-granting) + await queryRunner.query(`ALTER TABLE "projects" ADD "pipes_granted" integer NOT NULL DEFAULT 0`); + + // Create orders table + await queryRunner.query(` + CREATE TABLE "orders" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "user_id" uuid NOT NULL, + "shop_item_id" uuid, + "quantity" integer NOT NULL, + "pipes_spent" integer NOT NULL, + "item_name" varchar(200) NOT NULL, + "status" varchar(20) NOT NULL DEFAULT 'pending', + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_orders" PRIMARY KEY ("id"), + CONSTRAINT "FK_orders_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE, + CONSTRAINT "FK_orders_shop_item" FOREIGN KEY ("shop_item_id") REFERENCES "shop_items"("id") ON DELETE SET NULL + ) + `); + await queryRunner.query(`CREATE INDEX "IDX_orders_user_id" ON "orders"("user_id")`); + await queryRunner.query(`CREATE INDEX "IDX_orders_status" ON "orders"("status")`); + await queryRunner.query(`CREATE INDEX "IDX_orders_created_at" ON "orders"("created_at")`); + + // Create fulfillment_updates table + await queryRunner.query(` + CREATE TABLE "fulfillment_updates" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "user_id" uuid NOT NULL, + "order_id" uuid NOT NULL, + "message" varchar(500) NOT NULL, + "is_read" boolean NOT NULL DEFAULT false, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_fulfillment_updates" PRIMARY KEY ("id"), + CONSTRAINT "FK_fulfillment_updates_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE, + CONSTRAINT "FK_fulfillment_updates_order" FOREIGN KEY ("order_id") REFERENCES "orders"("id") ON DELETE CASCADE + ) + `); + await queryRunner.query(`CREATE INDEX "IDX_fulfillment_updates_user_id" ON "fulfillment_updates"("user_id")`); + await queryRunner.query(`CREATE INDEX "IDX_fulfillment_updates_user_read" ON "fulfillment_updates"("user_id", "is_read")`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "fulfillment_updates"`); + await queryRunner.query(`DROP TABLE "orders"`); + await queryRunner.query(`ALTER TABLE "projects" DROP COLUMN "pipes_granted"`); + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "pipes"`); + } +} diff --git a/backend/src/migrations/1776100000000-CreateSubmissions.ts b/backend/src/migrations/1776100000000-CreateSubmissions.ts new file mode 100644 index 0000000..1c38358 --- /dev/null +++ b/backend/src/migrations/1776100000000-CreateSubmissions.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CreateSubmissions1776100000000 implements MigrationInterface { + name = 'CreateSubmissions1776100000000' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "submissions" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "project_id" uuid NOT NULL, + "user_id" uuid NOT NULL, + "change_description" text, + "min_hours_confirmed" boolean NOT NULL DEFAULT false, + "status" varchar(20) NOT NULL DEFAULT 'unreviewed', + "override_hours" real, + "internal_hours" real, + "pipes_granted" integer NOT NULL DEFAULT 0, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_submissions" PRIMARY KEY ("id"), + CONSTRAINT "FK_submissions_project" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE, + CONSTRAINT "FK_submissions_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE + ) + `); + await queryRunner.query(`CREATE INDEX "IDX_submissions_project_id" ON "submissions"("project_id")`); + await queryRunner.query(`CREATE INDEX "IDX_submissions_status" ON "submissions"("status")`); + + // Link project_reviews to submissions instead of directly to projects + await queryRunner.query(`ALTER TABLE "project_reviews" ADD "submission_id" uuid`); + await queryRunner.query(`ALTER TABLE "project_reviews" ADD CONSTRAINT "FK_project_reviews_submission" FOREIGN KEY ("submission_id") REFERENCES "submissions"("id") ON DELETE CASCADE`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "project_reviews" DROP CONSTRAINT "FK_project_reviews_submission"`); + await queryRunner.query(`ALTER TABLE "project_reviews" DROP COLUMN "submission_id"`); + await queryRunner.query(`DROP TABLE "submissions"`); + } +} diff --git a/backend/src/migrations/1776200000000-AddHcaTokens.ts b/backend/src/migrations/1776200000000-AddHcaTokens.ts new file mode 100644 index 0000000..60891a8 --- /dev/null +++ b/backend/src/migrations/1776200000000-AddHcaTokens.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddHcaTokens1776200000000 implements MigrationInterface { + name = 'AddHcaTokens1776200000000' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "users" ADD "hca_access_token" text NULL`, + ); + await queryRunner.query( + `ALTER TABLE "users" ADD "hca_refresh_token" text NULL`, + ); + // Force all users to re-auth via HCA so we capture their tokens + await queryRunner.query(`TRUNCATE TABLE "sessions"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "users" DROP COLUMN "hca_refresh_token"`, + ); + await queryRunner.query( + `ALTER TABLE "users" DROP COLUMN "hca_access_token"`, + ); + } +} diff --git a/backend/src/migrations/1776300000000-AddShopDetailedDescription.ts b/backend/src/migrations/1776300000000-AddShopDetailedDescription.ts new file mode 100644 index 0000000..782a491 --- /dev/null +++ b/backend/src/migrations/1776300000000-AddShopDetailedDescription.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddShopDetailedDescription1776300000000 implements MigrationInterface { + name = 'AddShopDetailedDescription1776300000000' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "shop_items" ADD "detailed_description" text`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "shop_items" DROP COLUMN "detailed_description"`); + } +} diff --git a/backend/src/migrations/1776400000000-AddUserGender.ts b/backend/src/migrations/1776400000000-AddUserGender.ts new file mode 100644 index 0000000..c2c33b8 --- /dev/null +++ b/backend/src/migrations/1776400000000-AddUserGender.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddUserGender1776400000000 implements MigrationInterface { + name = 'AddUserGender1776400000000' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" ADD "gender" character varying`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "gender"`); + } +} diff --git a/backend/src/migrations/1777000000000-AddUserAttribution.ts b/backend/src/migrations/1777000000000-AddUserAttribution.ts new file mode 100644 index 0000000..b662f75 --- /dev/null +++ b/backend/src/migrations/1777000000000-AddUserAttribution.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddUserAttribution1777000000000 implements MigrationInterface { + name = 'AddUserAttribution1777000000000' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" ADD "utm_source" character varying`); + await queryRunner.query(`ALTER TABLE "users" ADD "utm_medium" character varying`); + await queryRunner.query(`ALTER TABLE "users" ADD "utm_campaign" character varying`); + await queryRunner.query(`ALTER TABLE "users" ADD "referrer" character varying`); + await queryRunner.query(`ALTER TABLE "users" ADD "landing_path" character varying`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "landing_path"`); + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "referrer"`); + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "utm_campaign"`); + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "utm_medium"`); + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "utm_source"`); + } +} diff --git a/backend/src/migrations/1777000000000-CreateHcbCredentialAndOrderGrant.ts b/backend/src/migrations/1777000000000-CreateHcbCredentialAndOrderGrant.ts new file mode 100644 index 0000000..f0d2fdf --- /dev/null +++ b/backend/src/migrations/1777000000000-CreateHcbCredentialAndOrderGrant.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CreateHcbCredentialAndOrderGrant1777000000000 implements MigrationInterface { + name = 'CreateHcbCredentialAndOrderGrant1777000000000' + + public async up(queryRunner: QueryRunner): Promise { + // Single-row store for the HCB OAuth connection (tokens encrypted at rest). + await queryRunner.query(` + CREATE TABLE "hcb_credentials" ( + "id" varchar(32) NOT NULL, + "access_token" text NOT NULL, + "refresh_token" text NOT NULL, + "expires_at" TIMESTAMP WITH TIME ZONE NOT NULL, + "scope" varchar(255), + "connected_by_user_id" uuid, + "connected_by_email" varchar(320), + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_hcb_credentials" PRIMARY KEY ("id") + ) + `); + + // Per-order idempotency lock: the HCB card grant public id (cdg_…). + await queryRunner.query(`ALTER TABLE "orders" ADD "hcb_card_grant_id" varchar(64)`); + // Enforce one grant per HCB card grant id at the DB level (defence in depth). + await queryRunner.query(`CREATE UNIQUE INDEX "UQ_orders_hcb_card_grant_id" ON "orders"("hcb_card_grant_id") WHERE "hcb_card_grant_id" IS NOT NULL`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "UQ_orders_hcb_card_grant_id"`); + await queryRunner.query(`ALTER TABLE "orders" DROP COLUMN "hcb_card_grant_id"`); + await queryRunner.query(`DROP TABLE "hcb_credentials"`); + } +} diff --git a/backend/src/migrations/1777100000000-CreateShopSuggestions.ts b/backend/src/migrations/1777100000000-CreateShopSuggestions.ts new file mode 100644 index 0000000..90f9f2b --- /dev/null +++ b/backend/src/migrations/1777100000000-CreateShopSuggestions.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateShopSuggestions1777100000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "shop_suggestions" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "user_id" uuid NOT NULL, + "text" varchar(200) NOT NULL, + "created_at" timestamp NOT NULL DEFAULT now(), + CONSTRAINT "PK_shop_suggestions" PRIMARY KEY ("id"), + CONSTRAINT "FK_shop_suggestions_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE + ) + `); + + await queryRunner.query(`CREATE INDEX "IDX_shop_suggestions_user_id" ON "shop_suggestions" ("user_id")`); + + await queryRunner.query(` + CREATE TABLE "shop_suggestion_votes" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "suggestion_id" uuid NOT NULL, + "user_id" uuid NOT NULL, + "created_at" timestamp NOT NULL DEFAULT now(), + CONSTRAINT "PK_shop_suggestion_votes" PRIMARY KEY ("id"), + CONSTRAINT "UQ_shop_suggestion_votes_user_suggestion" UNIQUE ("user_id", "suggestion_id"), + CONSTRAINT "FK_shop_suggestion_votes_suggestion" FOREIGN KEY ("suggestion_id") REFERENCES "shop_suggestions"("id") ON DELETE CASCADE, + CONSTRAINT "FK_shop_suggestion_votes_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE + ) + `); + + await queryRunner.query(`CREATE INDEX "IDX_shop_suggestion_votes_suggestion_id" ON "shop_suggestion_votes" ("suggestion_id")`); + await queryRunner.query(`CREATE INDEX "IDX_shop_suggestion_votes_user_id" ON "shop_suggestion_votes" ("user_id")`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "shop_suggestion_votes"`); + await queryRunner.query(`DROP TABLE "shop_suggestions"`); + } +} diff --git a/backend/src/migrations/1777200000000-BackfillProjectOverrideHours.ts b/backend/src/migrations/1777200000000-BackfillProjectOverrideHours.ts new file mode 100644 index 0000000..9e19124 --- /dev/null +++ b/backend/src/migrations/1777200000000-BackfillProjectOverrideHours.ts @@ -0,0 +1,48 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * One-shot data fix for projects where override_hours got silently zeroed (or + * reduced below pipes_granted) during a re-approval — see admin.service.ts:375 + * for the prevention. Without this fix the home progress bar shows 0 hours for + * the project even though the user keeps the pipes that were granted on the + * earlier approval. + * + * Restore target: the highest override_hours seen on any submission for the + * project. If no submission carries a usable value we fall back to + * pipes_granted, which is the minimum that re-aligns the bar with the granted + * pipes (pipes_granted = floor(override_hours), so override_hours >= pipes_granted + * is the invariant). GREATEST guarantees we never restore below pipes_granted. + * + * The corresponding code change rejects future approvals that would + * re-introduce this state, so this migration is a one-shot — the down() is a + * no-op because we cannot reconstruct the corrupted zero values, nor would we + * want to. + */ +export class BackfillProjectOverrideHours1777200000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE projects p + SET override_hours = GREATEST( + COALESCE( + ( + SELECT MAX(s.override_hours) + FROM submissions s + WHERE s.project_id = p.id + AND s.override_hours IS NOT NULL + ), + p.pipes_granted + ), + p.pipes_granted + ) + WHERE p.pipes_granted > 0 + AND (p.override_hours IS NULL OR p.override_hours < p.pipes_granted) + `); + } + + public async down(): Promise { + // No-op: this is a one-shot correction. Reversing it would re-introduce + // the bug. + } +} diff --git a/backend/src/migrations/1777300000000-CreateDevlogs.ts b/backend/src/migrations/1777300000000-CreateDevlogs.ts new file mode 100644 index 0000000..187dbbd --- /dev/null +++ b/backend/src/migrations/1777300000000-CreateDevlogs.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateDevlogs1777300000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "devlogs" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "user_id" uuid NOT NULL, + "project_id" uuid NULL, + "text" text NOT NULL, + "image_urls" text NULL, + "created_at" timestamp NOT NULL DEFAULT now(), + CONSTRAINT "PK_devlogs" PRIMARY KEY ("id"), + CONSTRAINT "FK_devlogs_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE, + CONSTRAINT "FK_devlogs_project" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE SET NULL + ) + `); + + await queryRunner.query( + `CREATE INDEX "IDX_devlogs_user_id" ON "devlogs" ("user_id")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_devlogs_project_id" ON "devlogs" ("project_id")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_devlogs_created_at" ON "devlogs" ("created_at" DESC)`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "devlogs"`); + } +} diff --git a/backend/src/migrations/1777300000100-AddDevlogTitle.ts b/backend/src/migrations/1777300000100-AddDevlogTitle.ts new file mode 100644 index 0000000..7b30184 --- /dev/null +++ b/backend/src/migrations/1777300000100-AddDevlogTitle.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddDevlogTitle1777300000100 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Add as NOT NULL with an empty default so any pre-existing rows fit; + // application layer enforces non-empty going forward. + await queryRunner.query( + `ALTER TABLE "devlogs" ADD COLUMN "title" varchar(120) NOT NULL DEFAULT ''`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "devlogs" DROP COLUMN "title"`); + } +} diff --git a/backend/src/migrations/1778000000000-CreateFraudReviews.ts b/backend/src/migrations/1778000000000-CreateFraudReviews.ts new file mode 100644 index 0000000..0f21d74 --- /dev/null +++ b/backend/src/migrations/1778000000000-CreateFraudReviews.ts @@ -0,0 +1,66 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateFraudReviews1778000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "fraud_reviews" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "project_id" uuid NOT NULL, + "remote_project_id" varchar(64) NULL, + "status" varchar(20) NOT NULL DEFAULT 'pending', + "trust_score" integer NULL, + "justification" text NULL, + "outcome_recorded" boolean NOT NULL DEFAULT false, + "reviewed_at" timestamp NULL, + "created_at" timestamp NOT NULL DEFAULT now(), + "updated_at" timestamp NOT NULL DEFAULT now(), + CONSTRAINT "PK_fraud_reviews" PRIMARY KEY ("id"), + CONSTRAINT "UQ_fraud_reviews_project_id" UNIQUE ("project_id"), + CONSTRAINT "FK_fraud_reviews_project" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE + ) + `); + + await queryRunner.query( + `CREATE INDEX "IDX_fraud_reviews_status" ON "fraud_reviews" ("status")`, + ); + + // Make project_reviews.reviewer_id nullable so fraud-rejection reviews + // (system-authored, no human reviewer) can be inserted. Also relax the FK + // ON DELETE CASCADE → SET NULL so deleting a reviewer account doesn't + // wipe the historical review trail. + await queryRunner.query( + `ALTER TABLE "project_reviews" ALTER COLUMN "reviewer_id" DROP NOT NULL`, + ); + // Find and drop the existing FK by introspection (constraint name may vary + // across environments depending on TypeORM auto-generation). + await queryRunner.query(` + DO $$ + DECLARE fk_name text; + BEGIN + SELECT conname INTO fk_name + FROM pg_constraint + WHERE conrelid = 'project_reviews'::regclass + AND contype = 'f' + AND conkey = ARRAY[( + SELECT attnum FROM pg_attribute + WHERE attrelid = 'project_reviews'::regclass AND attname = 'reviewer_id' + )] + LIMIT 1; + IF fk_name IS NOT NULL THEN + EXECUTE format('ALTER TABLE project_reviews DROP CONSTRAINT %I', fk_name); + END IF; + END$$; + `); + await queryRunner.query(` + ALTER TABLE "project_reviews" + ADD CONSTRAINT "FK_project_reviews_reviewer" + FOREIGN KEY ("reviewer_id") REFERENCES "users"("id") ON DELETE SET NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "fraud_reviews"`); + // Note: not reverting the project_reviews.reviewer_id nullability change + // here because down-migrating could fail on rows with NULL reviewers. + } +} diff --git a/backend/src/migrations/1778100000000-AddShopItemFeatured.ts b/backend/src/migrations/1778100000000-AddShopItemFeatured.ts new file mode 100644 index 0000000..ec382fd --- /dev/null +++ b/backend/src/migrations/1778100000000-AddShopItemFeatured.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddShopItemFeatured1778100000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "shop_items" ADD COLUMN "is_featured" boolean NOT NULL DEFAULT false`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "shop_items" DROP COLUMN "is_featured"`); + } +} diff --git a/backend/src/migrations/1778200000000-AddSubmissionReviewerNote.ts b/backend/src/migrations/1778200000000-AddSubmissionReviewerNote.ts new file mode 100644 index 0000000..4eb782d --- /dev/null +++ b/backend/src/migrations/1778200000000-AddSubmissionReviewerNote.ts @@ -0,0 +1,11 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddSubmissionReviewerNote1778200000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "submissions" ADD COLUMN "reviewer_note" text`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "submissions" DROP COLUMN "reviewer_note"`); + } +} diff --git a/backend/src/migrations/1778300000000-AddUserIntent.ts b/backend/src/migrations/1778300000000-AddUserIntent.ts new file mode 100644 index 0000000..1b1fd20 --- /dev/null +++ b/backend/src/migrations/1778300000000-AddUserIntent.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddUserIntent1778300000000 implements MigrationInterface { + name = 'AddUserIntent1778300000000' + + public async up(queryRunner: QueryRunner): Promise { + // Answer to the one-time home "hackathon or shop?" prompt. NULL until answered. + await queryRunner.query(`ALTER TABLE "users" ADD "intent" varchar(20)`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "intent"`); + } +} diff --git a/backend/src/news/news.controller.ts b/backend/src/news/news.controller.ts new file mode 100644 index 0000000..7e0a319 --- /dev/null +++ b/backend/src/news/news.controller.ts @@ -0,0 +1,22 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NewsItem } from '../entities/news-item.entity'; + +@Controller('api/news') +export class NewsController { + constructor( + @InjectRepository(NewsItem) + private readonly newsRepo: Repository, + ) {} + + @UseGuards(JwtAuthGuard) + @Get() + async list() { + return this.newsRepo.find({ + order: { displayDate: 'DESC', createdAt: 'DESC' }, + select: ['id', 'text', 'displayDate'], + }); + } +} diff --git a/backend/src/news/news.module.ts b/backend/src/news/news.module.ts new file mode 100644 index 0000000..82e7bf2 --- /dev/null +++ b/backend/src/news/news.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthModule } from '../auth/auth.module'; +import { NewsItem } from '../entities/news-item.entity'; +import { NewsController } from './news.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([NewsItem]), AuthModule], + controllers: [NewsController], +}) +export class NewsModule {} diff --git a/backend/src/onboarding/onboarding-backfill.service.ts b/backend/src/onboarding/onboarding-backfill.service.ts new file mode 100644 index 0000000..1d3d5af --- /dev/null +++ b/backend/src/onboarding/onboarding-backfill.service.ts @@ -0,0 +1,74 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Not, IsNull, Repository } from 'typeorm'; +import { User } from '../entities/user.entity'; +import { Project } from '../entities/project.entity'; +import { RsvpService } from '../rsvp/rsvp.service'; + +@Injectable() +export class OnboardingBackfillService implements OnModuleInit { + private readonly logger = new Logger(OnboardingBackfillService.name); + + constructor( + @InjectRepository(User) private readonly userRepo: Repository, + @InjectRepository(Project) private readonly projectRepo: Repository, + private readonly rsvpService: RsvpService, + private readonly config: ConfigService, + ) {} + + onModuleInit() { + if (this.config.get('ONBOARDING_BACKFILL') !== '1') return; + // Run in background so app startup isn't blocked — backfill can take minutes at Airtable rate limits + setImmediate(() => this.runBackfill().catch((err) => this.logger.error(`Backfill failed: ${err}`))); + } + + private async runBackfill() { + this.logger.log('ONBOARDING_BACKFILL=1 — running one-shot funnel backfill in background...'); + + const hackatimeUsers = await this.userRepo.find({ + where: { hackatimeToken: Not(IsNull()) }, + select: ['id', 'email'], + }); + const projectUserIds = new Set( + (await this.projectRepo.createQueryBuilder('p') + .select('DISTINCT p.user_id', 'user_id') + .getRawMany()).map((r) => r.user_id), + ); + + const onboardedCandidates = await this.userRepo.find({ + select: ['id', 'email', 'hackatimeToken', 'twoEmails'], + }); + const onboardedUsers = onboardedCandidates.filter( + (u) => u.email && (u.hackatimeToken || u.twoEmails || projectUserIds.has(u.id)), + ); + + this.logger.log( + `Backfill targets: ${hackatimeUsers.length} hackatime-synched, ${onboardedUsers.length} onboarded`, + ); + + // Users who have projects with linked Hackatime project names + const detailedProjectUsers = await this.projectRepo + .createQueryBuilder('p') + .innerJoin('p.user', 'u') + .where('p.hackatime_project_name IS NOT NULL') + .andWhere("p.hackatime_project_name != '[]'") + .select('DISTINCT u.email', 'email') + .getRawMany(); + + for (const u of hackatimeUsers) { + if (u.email) await this.rsvpService.updateDateField(u.email, 'Loops - beestHackatimeSynched'); + } + for (const u of onboardedUsers) { + await this.rsvpService.updateDateField(u.email, 'Loops - beestOnboarded'); + } + for (const row of detailedProjectUsers) { + if (row.email) await this.rsvpService.updateDateField(row.email, 'Loops - beestDetailedProject'); + } + + this.logger.log( + `Backfill targets: ${detailedProjectUsers.length} detailed-project`, + ); + this.logger.log('Onboarding funnel backfill complete. Unset ONBOARDING_BACKFILL to skip on next boot.'); + } +} diff --git a/backend/src/onboarding/onboarding.controller.ts b/backend/src/onboarding/onboarding.controller.ts new file mode 100644 index 0000000..197edc3 --- /dev/null +++ b/backend/src/onboarding/onboarding.controller.ts @@ -0,0 +1,115 @@ +import { Controller, Get, Post, Req, UseGuards, Logger } from '@nestjs/common'; +import { Throttle } from '@nestjs/throttler'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import type { Request } from 'express'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { HackatimeService } from '../hackatime/hackatime.service'; +import { SlackService } from '../slack/slack.service'; +import { ProjectsService } from '../projects/projects.service'; +import { RsvpService } from '../rsvp/rsvp.service'; +import { User } from '../entities/user.entity'; + +@Controller('api/onboarding') +export class OnboardingController { + private readonly logger = new Logger(OnboardingController.name); + + constructor( + private readonly hackatimeService: HackatimeService, + private readonly slackService: SlackService, + private readonly projectsService: ProjectsService, + private readonly rsvpService: RsvpService, + @InjectRepository(User) + private readonly userRepo: Repository, + ) {} + + @Throttle({ default: { limit: 5, ttl: 60000 } }) + @UseGuards(JwtAuthGuard) + @Post('two-emails') + async setTwoEmails(@Req() req: Request) { + const user = (req as any).user; + await this.userRepo.update({ hcaSub: user.sub }, { twoEmails: true }); + return { ok: true }; + } + + @Throttle({ default: { limit: 10, ttl: 60000 } }) + @UseGuards(JwtAuthGuard) + @Post('mark-onboarded') + async markOnboarded(@Req() req: Request) { + const user = (req as any).user; + const dbUser = await this.userRepo.findOne({ + where: { hcaSub: user.sub }, + select: ['email'], + }); + if (dbUser?.email) { + this.rsvpService.updateDateField(dbUser.email, 'Loops - beestOnboarded'); + } + return { ok: true }; + } + + /** + * Returns completion status for each onboarding step. + * The frontend uses this to show "Complete! Move on?" vs action buttons. + */ + @Throttle({ default: { limit: 15, ttl: 60000 } }) + @UseGuards(JwtAuthGuard) + @Get('status') + async getStatus(@Req() req: Request) { + const user = (req as any).user; + + // Check Slack membership by email + let slack: 'full_member' | 'guest' | 'not_found' | 'error' = 'not_found'; + try { + const dbUser = await this.userRepo.findOne({ + where: { hcaSub: user.sub }, + select: ['email', 'twoEmails'], + }); + if (dbUser?.twoEmails) { + // User confirmed they use a different email on Slack — skip email lookup + slack = 'full_member'; + } else if (dbUser?.email) { + slack = await this.slackService.checkMembership(dbUser.email); + } + } catch (err) { + this.logger.error(`Slack membership check failed for ${user.sub}: ${err}`); + slack = 'error'; + } + + const [hackatime, project] = await Promise.all([ + this.hackatimeService.isConnected(user.sub), + this.projectsService.userHasProjects(user.uid), + ]); + + // Sync tutorial completion date to Airtable for Loops + const slackDone = slack === 'full_member'; + if (hackatime && slackDone && project) { + const dbUserForSync = await this.userRepo.findOne({ + where: { hcaSub: user.sub }, + select: ['email'], + }); + if (dbUserForSync?.email) { + this.rsvpService.updateDateField(dbUserForSync.email, 'Loops - beestCompletedTutorial'); + } + } + + return { hackatime, slack, project }; + } + + @Throttle({ default: { limit: 10, ttl: 60000 } }) + @UseGuards(JwtAuthGuard) + @Get('sticker-link') + async getStickerLink(@Req() req: Request) { + const user = (req as any).user; + const dbUser = await this.userRepo.findOne({ + where: { hcaSub: user.sub }, + select: ['email'], + }); + + if (!dbUser?.email) { + return { link: null }; + } + + const link = await this.rsvpService.getStickerLink(dbUser.email); + return { link }; + } +} diff --git a/backend/src/onboarding/onboarding.module.ts b/backend/src/onboarding/onboarding.module.ts new file mode 100644 index 0000000..17039c9 --- /dev/null +++ b/backend/src/onboarding/onboarding.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthModule } from '../auth/auth.module'; +import { HackatimeModule } from '../hackatime/hackatime.module'; +import { SlackModule } from '../slack/slack.module'; +import { ProjectsModule } from '../projects/projects.module'; +import { RsvpModule } from '../rsvp/rsvp.module'; +import { User } from '../entities/user.entity'; +import { Project } from '../entities/project.entity'; +import { OnboardingController } from './onboarding.controller'; +import { OnboardingBackfillService } from './onboarding-backfill.service'; + +@Module({ + imports: [AuthModule, HackatimeModule, SlackModule, ProjectsModule, RsvpModule, TypeOrmModule.forFeature([User, Project])], + controllers: [OnboardingController], + providers: [OnboardingBackfillService], +}) +export class OnboardingModule {} diff --git a/backend/src/projects/create-project.dto.ts b/backend/src/projects/create-project.dto.ts new file mode 100644 index 0000000..6bf94f0 --- /dev/null +++ b/backend/src/projects/create-project.dto.ts @@ -0,0 +1,13 @@ +export class CreateProjectDto { + name: string; + description: string; + projectType: string; + codeUrl?: string; + readmeUrl?: string; + demoUrl?: string; + screenshots?: string[]; + hackatimeProjectName?: string[]; + isUpdate?: boolean; + otherHcProgram?: string; + aiUse?: string; +} diff --git a/backend/src/projects/leaderboard.controller.ts b/backend/src/projects/leaderboard.controller.ts new file mode 100644 index 0000000..013e386 --- /dev/null +++ b/backend/src/projects/leaderboard.controller.ts @@ -0,0 +1,105 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { Throttle } from '@nestjs/throttler'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { HackatimeService } from '../hackatime/hackatime.service'; +import { User } from '../entities/user.entity'; +import { ProjectsService } from './projects.service'; + +type LeaderboardEntry = { name: string; hours: number }; + +@Controller('api/leaderboard') +export class LeaderboardController { + // Computing the full leaderboard requires one Hackatime /projects call per + // builder, so pagination caches the sorted list for a short window. Every + // "show more" click hits the cache rather than re-fanning out to Hackatime. + private cache: { + entries: LeaderboardEntry[]; + totalUsers: number; + timestamp: number; + } | null = null; + private static readonly CACHE_TTL_MS = 60_000; + + constructor( + private readonly projectsService: ProjectsService, + private readonly hackatimeService: HackatimeService, + @InjectRepository(User) + private readonly userRepo: Repository, + ) {} + + /** + * Returns a paginated slice of the leaderboard, sorted by approved Hackatime + * hours descending. `limit` defaults to 10, capped at 100. No PII is exposed + * — only display names and hours. + */ + @Throttle({ default: { limit: 15, ttl: 60000 } }) + @UseGuards(JwtAuthGuard) + @Get() + async getLeaderboard( + @Query('limit') limitRaw?: string, + @Query('offset') offsetRaw?: string, + ) { + const limit = Math.min(100, Math.max(1, Number.parseInt(limitRaw ?? '', 10) || 10)); + const offset = Math.max(0, Number.parseInt(offsetRaw ?? '', 10) || 0); + + const { entries, totalUsers } = await this.getEntries(); + + return { + leaderboard: entries.slice(offset, offset + limit), + totalUsers, + total: entries.length, + offset, + limit, + }; + } + + private async getEntries(): Promise<{ entries: LeaderboardEntry[]; totalUsers: number }> { + if (this.cache && Date.now() - this.cache.timestamp < LeaderboardController.CACHE_TTL_MS) { + return { entries: this.cache.entries, totalUsers: this.cache.totalUsers }; + } + + const [grouped, totalUsers] = await Promise.all([ + this.projectsService.findApprovedProjectsGroupedByUser(), + this.userRepo.count(), + ]); + + const results = await Promise.allSettled( + Array.from(grouped.entries()).map(async ([, entry]) => { + const allNames = [ + ...new Set(entry.projects.flatMap((p) => p.hackatimeProjectNames)), + ]; + const { perProject } = await this.hackatimeService.getHoursForProjects( + entry.hcaSub, + allNames, + ); + // Approved hours per project = min(overrideHours, hackatime hours on its names). + // Mirrors the per-user calc in projects.controller.ts#getHours so the + // leaderboard shows what was actually locked in at approval, not raw logged time. + let hours = 0; + for (const proj of entry.projects) { + let projHours = 0; + for (const name of proj.hackatimeProjectNames) { + projHours += perProject[name] ?? 0; + } + hours += Math.min(proj.overrideHours, projHours); + } + return { + name: entry.nickname || entry.name || 'Anonymous', + hours, + }; + }), + ); + + const entries: LeaderboardEntry[] = results + .filter( + (r): r is PromiseFulfilledResult => + r.status === 'fulfilled' && r.value.hours > 0, + ) + .map((r) => r.value) + .sort((a, b) => b.hours - a.hours); + + this.cache = { entries, totalUsers, timestamp: Date.now() }; + return { entries, totalUsers }; + } +} diff --git a/backend/src/projects/project-airtable-sync.module.ts b/backend/src/projects/project-airtable-sync.module.ts new file mode 100644 index 0000000..c87d69c --- /dev/null +++ b/backend/src/projects/project-airtable-sync.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { RsvpModule } from '../rsvp/rsvp.module'; +import { HcaModule } from '../hca/hca.module'; +import { ProjectAirtableSyncService } from './project-airtable-sync.service'; + +@Module({ + imports: [RsvpModule, HcaModule], + providers: [ProjectAirtableSyncService], + exports: [ProjectAirtableSyncService], +}) +export class ProjectAirtableSyncModule {} diff --git a/backend/src/projects/project-airtable-sync.service.ts b/backend/src/projects/project-airtable-sync.service.ts new file mode 100644 index 0000000..daca65e --- /dev/null +++ b/backend/src/projects/project-airtable-sync.service.ts @@ -0,0 +1,76 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Project } from '../entities/project.entity'; +import { Submission } from '../entities/submission.entity'; +import { RsvpService } from '../rsvp/rsvp.service'; +import { HcaService } from '../hca/hca.service'; + +/** + * Builds the Airtable Projects-table field map for an approved project and + * pushes it. Extracted so both the existing SuperAdmin resync endpoint and the + * fraud-review poller can reuse it (the poller is now what triggers Airtable + * sync on first-time approvals — direct beest review no longer does). + */ +@Injectable() +export class ProjectAirtableSyncService { + private readonly logger = new Logger(ProjectAirtableSyncService.name); + + constructor( + private readonly rsvpService: RsvpService, + private readonly hcaService: HcaService, + ) {} + + async syncApprovedProject( + project: Project, + overrideJustification: string | null, + submission: Submission | null, + ): Promise { + if (!project.user?.email) { + this.logger.warn(`Skipping Airtable sync for ${project.id} — no user email`); + return; + } + + const identity = await this.hcaService.getIdentity(project.user.hcaSub); + const address = identity?.address ?? {}; + const streetLines = (address.street_address ?? '').split(/\r?\n/); + + const fullName = identity?.name ?? ''; + const [splitFirst, ...splitRest] = fullName.split(' '); + const firstName = (identity as any)?.given_name ?? splitFirst; + const lastName = (identity as any)?.family_name ?? splitRest.join(' '); + + const screenshots = [project.screenshot1Url, project.screenshot2Url] + .filter((url): url is string => !!url) + .map((url) => ({ url })); + + const shipInternalHours = submission?.internalHours ?? project.internalHours; + + const fields: Record = { + 'First Name': firstName, + 'Last Name': lastName, + Description: project.description, + Email: project.user.email, + 'Playable URL': project.demoUrl, + 'Code URL': project.codeUrl, + Screenshot: screenshots, + 'Address (Line 1)': streetLines[0], + 'Address (Line 2)': streetLines.slice(1).join(', '), + City: address.locality, + 'State / Province': address.region, + Country: address.country, + 'ZIP / Postal Code': address.postal_code, + Birthday: identity?.birthdate, + 'Override Hours Spent': shipInternalHours, + 'Override Hours Spent Justification': overrideJustification, + }; + + const cleanFields = Object.fromEntries( + Object.entries(fields).filter(([, v]) => { + if (v === null || v === undefined || v === '') return false; + if (Array.isArray(v) && v.length === 0) return false; + return true; + }), + ); + + await this.rsvpService.createApprovedProjectRecord(cleanFields); + } +} diff --git a/backend/src/projects/projects.controller.ts b/backend/src/projects/projects.controller.ts new file mode 100644 index 0000000..c924d9c --- /dev/null +++ b/backend/src/projects/projects.controller.ts @@ -0,0 +1,270 @@ +import { + Controller, + Post, + Get, + Patch, + Delete, + Body, + Param, + Req, + UseGuards, + UnauthorizedException, +} from '@nestjs/common'; +import { Throttle } from '@nestjs/throttler'; +import type { Request } from 'express'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { HackatimeService } from '../hackatime/hackatime.service'; +import { Project } from '../entities/project.entity'; +import { ProjectReview } from '../entities/project-review.entity'; +import { ProjectsService } from './projects.service'; +import { CreateProjectDto } from './create-project.dto'; +import { UpdateProjectDto } from './update-project.dto'; + +@Controller('api/projects') +export class ProjectsController { + constructor( + private readonly projectsService: ProjectsService, + private readonly hackatimeService: HackatimeService, + @InjectRepository(Project) private readonly projectRepo: Repository, + @InjectRepository(ProjectReview) private readonly reviewRepo: Repository, + ) {} + + /** + * Returns total Hackatime hours across all of the user's linked projects. + * Called on page load — Hackatime time increases without site interaction. + */ + @Throttle({ default: { limit: 15, ttl: 60000 } }) + @UseGuards(JwtAuthGuard) + @Get('hours') + async getHours(@Req() req: Request) { + const user = (req as any).user; + if (!user?.uid) throw new UnauthorizedException('No user identity'); + + const projects = await this.projectsService.findByUser(user.uid); + const linkedNames = projects + .flatMap((p) => p.hackatimeProjectName ?? []) + .filter((n): n is string => !!n); + + const { perProject } = await this.hackatimeService.getHoursForProjects( + user.sub, + [...new Set(linkedNames)], + ); + + const byStatus: Record = {}; + let displayHours = 0; + for (const p of projects) { + const names = p.hackatimeProjectName ?? []; + // Bucket fraud_pending under 'unreviewed' for the user's progress bar — + // from the user's perspective these hours are still in review. + const rawStatus = p.status ?? 'unshipped'; + const status = rawStatus === 'fraud_pending' ? 'unreviewed' : rawStatus; + + let currentHours = 0; + for (const name of names) { + if (perProject[name]) currentHours += perProject[name]; + } + + // Earned hours = the hours the user has been credited pipes for. Locked in + // regardless of Hackatime's current state (renames, deletions, re-syncs). + // A direct approved → changes_needed clears overrideHours/pipesGranted; the + // unreviewed → changes_needed path keeps both, so pipes_granted > 0 is the + // source of truth for credited hours. + // + // Edge case: legacy approvals from before admin.service started rejecting + // <= 0 hours could land at overrideHours=0/pipesGranted=0 with status='approved'. + // In that broken state we fall back to current Hackatime hours so the user + // can see their work — re-review by an admin will lock in real values. + let earnedHours = (p.pipesGranted ?? 0) > 0 ? (p.overrideHours ?? 0) : 0; + if (status === 'approved' && earnedHours === 0 && currentHours > 0) { + earnedHours = currentHours; + } + if (earnedHours > 0) { + byStatus['approved'] = (byStatus['approved'] ?? 0) + earnedHours; + displayHours += earnedHours; + } + + // Hackatime hours beyond what's already been earned belong to the project's + // current review state. For approved projects we hide them — they don't become + // user-visible until the user resubmits (status flips to 'unreviewed'), at which + // point this branch surfaces them in the right bucket. Showing them as + // 'unshipped' while the project is approved was misleading. + if (status !== 'approved') { + const remainder = Math.max(0, currentHours - earnedHours); + if (remainder > 0) { + byStatus[status] = (byStatus[status] ?? 0) + remainder; + displayHours += remainder; + } + } + } + + return { hours: Math.round(displayHours * 10) / 10, byStatus }; + } + + @Throttle({ default: { limit: 10, ttl: 60000 } }) + @UseGuards(JwtAuthGuard) + @Get('explore') + async explore() { + return this.projectsService.findApprovedProjects(); + } + + /** + * Public endpoint used by the shipping guide page. + * Returns average time-to-first-review per project type. + */ + @Throttle({ default: { limit: 10, ttl: 60000 } }) + @Get('review-stats') + async reviewStats() { + return this.projectsService.getReviewStats(); + } + + @Throttle({ default: { limit: 15, ttl: 60000 } }) + @UseGuards(JwtAuthGuard) + @Get('explore/:id') + async exploreDetail(@Param('id') id: string) { + const project = await this.projectsService.findApprovedProjectById(id); + if (!project) throw new UnauthorizedException('Project not found'); + return project; + } + + @Throttle({ default: { limit: 30, ttl: 60000 } }) + @UseGuards(JwtAuthGuard) + @Get('explore/:id/comments') + async getComments(@Param('id') projectId: string) { + return this.projectsService.getComments(projectId); + } + + @Throttle({ default: { limit: 10, ttl: 60000 } }) + @UseGuards(JwtAuthGuard) + @Post('explore/:id/comments') + async addComment( + @Param('id') projectId: string, + @Req() req: Request, + @Body() body: { body?: string }, + ) { + const user = (req as any).user; + if (!user?.uid) throw new UnauthorizedException('No user identity'); + return this.projectsService.addComment(projectId, user.uid, body.body ?? ''); + } + + @Throttle({ default: { limit: 10, ttl: 60000 } }) + @UseGuards(JwtAuthGuard) + @Delete('explore/:id/comments/:commentId') + async deleteComment( + @Param('commentId') commentId: string, + @Req() req: Request, + ) { + const user = (req as any).user; + if (!user?.uid) throw new UnauthorizedException('No user identity'); + return this.projectsService.deleteComment(commentId, user.uid, user.email); + } + + @Throttle({ default: { limit: 10, ttl: 60000 } }) + @UseGuards(JwtAuthGuard) + @Post() + async create(@Req() req: Request, @Body() dto: CreateProjectDto) { + const user = (req as any).user; + if (!user?.uid) throw new UnauthorizedException('No user identity'); + + return this.projectsService.create(dto, user.uid, user.sub, user.impersonator_name); + } + + @Throttle({ default: { limit: 30, ttl: 60000 } }) + @UseGuards(JwtAuthGuard) + @Get() + async list(@Req() req: Request) { + const userId = (req as any).user?.uid; + if (!userId) throw new UnauthorizedException('No user identity'); + + return this.projectsService.findByUser(userId); + } + + @Throttle({ default: { limit: 10, ttl: 60000 } }) + @UseGuards(JwtAuthGuard) + @Patch(':id') + async update( + @Param('id') id: string, + @Req() req: Request, + @Body() dto: UpdateProjectDto, + ) { + const user = (req as any).user; + if (!user?.uid) throw new UnauthorizedException('No user identity'); + + return this.projectsService.update(id, dto, user.uid, user.sub, user.impersonator_name); + } + + @Throttle({ default: { limit: 10, ttl: 60000 } }) + @UseGuards(JwtAuthGuard) + @Post(':id/resubmit') + async resubmit( + @Param('id') id: string, + @Req() req: Request, + @Body() body: { changeDescription?: string; minHoursConfirmed?: boolean; reviewerNote?: string | null }, + ) { + const user = (req as any).user; + if (!user?.uid) throw new UnauthorizedException('No user identity'); + if (!body.changeDescription || typeof body.changeDescription !== 'string') { + throw new UnauthorizedException('changeDescription is required'); + } + return this.projectsService.resubmit( + id, + user.uid, + user.sub, + body.changeDescription, + body.minHoursConfirmed === true, + body.reviewerNote ?? null, + user.impersonator_name, + ); + } + + @Throttle({ default: { limit: 10, ttl: 60000 } }) + @UseGuards(JwtAuthGuard) + @Delete(':id') + async delete(@Param('id') id: string, @Req() req: Request) { + const user = (req as any).user; + if (!user?.uid) throw new UnauthorizedException('No user identity'); + + await this.projectsService.delete(id, user.uid, user.impersonator_name); + return { deleted: true }; + } + + @Throttle({ default: { limit: 30, ttl: 60000 } }) + @UseGuards(JwtAuthGuard) + @Get(':id/queue-position') + async queuePosition(@Param('id') id: string, @Req() req: Request) { + const userId = (req as any).user?.uid; + if (!userId) throw new UnauthorizedException('No user identity'); + return this.projectsService.getQueuePosition(id, userId); + } + + @Throttle({ default: { limit: 30, ttl: 60000 } }) + @UseGuards(JwtAuthGuard) + @Get(':id/reviews') + async getReviews(@Param('id') id: string, @Req() req: Request) { + const userId = (req as any).user?.uid; + if (!userId) throw new UnauthorizedException('No user identity'); + + // Verify the project belongs to the authenticated user + const project = await this.projectRepo.findOne({ + where: { id, userId }, + select: ['id'], + }); + if (!project) throw new UnauthorizedException('Project not found'); + + const reviews = await this.reviewRepo.find({ + where: { projectId: id }, + order: { createdAt: 'DESC' }, + relations: ['reviewer'], + }); + + // Never expose internal notes to the user + return reviews.map((r) => ({ + id: r.id, + status: r.status, + feedback: r.feedback, + reviewerName: r.reviewer?.name ?? null, + createdAt: r.createdAt, + })); + } +} diff --git a/backend/src/projects/projects.module.ts b/backend/src/projects/projects.module.ts new file mode 100644 index 0000000..f9c42b0 --- /dev/null +++ b/backend/src/projects/projects.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthModule } from '../auth/auth.module'; +import { AuditLogModule } from '../audit-log/audit-log.module'; +import { HackatimeModule } from '../hackatime/hackatime.module'; +import { RsvpModule } from '../rsvp/rsvp.module'; +import { IdentityModule } from '../identity/identity.module'; +import { Project } from '../entities/project.entity'; +import { ProjectReview } from '../entities/project-review.entity'; +import { Comment } from '../entities/comment.entity'; +import { Submission } from '../entities/submission.entity'; +import { User } from '../entities/user.entity'; +import { ProjectsController } from './projects.controller'; +import { LeaderboardController } from './leaderboard.controller'; +import { ProjectsService } from './projects.service'; + +@Module({ + imports: [AuthModule, AuditLogModule, HackatimeModule, RsvpModule, IdentityModule, TypeOrmModule.forFeature([Project, ProjectReview, Comment, Submission, User])], + controllers: [ProjectsController, LeaderboardController], + providers: [ProjectsService], + exports: [ProjectsService], +}) +export class ProjectsModule {} diff --git a/backend/src/projects/projects.service.ts b/backend/src/projects/projects.service.ts new file mode 100644 index 0000000..8ca222f --- /dev/null +++ b/backend/src/projects/projects.service.ts @@ -0,0 +1,1008 @@ +import { Injectable, BadRequestException, NotFoundException, ForbiddenException, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Project, VALID_PROJECT_TYPES } from '../entities/project.entity'; +import { Comment } from '../entities/comment.entity'; +import { Submission } from '../entities/submission.entity'; +import { User } from '../entities/user.entity'; +import { fetchWithTimeout } from '../fetch.util'; +import { AuditLogService } from '../audit-log/audit-log.service'; +import { HackatimeService } from '../hackatime/hackatime.service'; +import { RsvpService } from '../rsvp/rsvp.service'; +import { IdentityService } from '../identity/identity.service'; +import { CreateProjectDto } from './create-project.dto'; +import { UpdateProjectDto } from './update-project.dto'; + +const CDN_UPLOAD_URL = 'https://cdn.hackclub.com/api/v4/upload'; + +/** MIME → file extension mapping for uploaded screenshots. */ +const MIME_EXTENSIONS: Record = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/gif': 'gif', + 'image/webp': 'webp', +}; + +/** PNG, JPEG, GIF, WEBP magic-byte prefixes (base64-encoded first bytes). */ +const IMAGE_SIGNATURES: { mime: string; b64Prefix: string }[] = [ + { mime: 'image/png', b64Prefix: 'iVBOR' }, + { mime: 'image/jpeg', b64Prefix: '/9j/' }, + { mime: 'image/gif', b64Prefix: 'R0lGOD' }, + { mime: 'image/webp', b64Prefix: 'UklGR' }, +]; + +@Injectable() +export class ProjectsService { + private readonly logger = new Logger(ProjectsService.name); + private readonly cdnApiKey: string; + + constructor( + private configService: ConfigService, + private auditLogService: AuditLogService, + private hackatimeService: HackatimeService, + private rsvpService: RsvpService, + private identityService: IdentityService, + @InjectRepository(Project) + private projectRepo: Repository, + @InjectRepository(Comment) + private commentRepo: Repository, + @InjectRepository(Submission) + private submissionRepo: Repository, + @InjectRepository(User) + private userRepo: Repository, + ) { + this.cdnApiKey = this.configService.getOrThrow('CDN_API_KEY'); + } + + /* ------------------------------------------------------------------ */ + /* Public */ + /* ------------------------------------------------------------------ */ + + async create( + dto: CreateProjectDto, + userId: string, + hcaSub: string, + impersonatorName?: string, + ) { + // --- required fields --- + const name = this.requireString(dto.name, 'name', 50); + const description = this.requireString(dto.description, 'description', 300); + const projectType = this.validateProjectType(dto.projectType); + + // --- optional URLs --- + const codeUrl = this.validateUrl(dto.codeUrl, 'codeUrl'); + const readmeUrl = this.validateUrl(dto.readmeUrl, 'readmeUrl'); + const demoUrl = this.validateUrl(dto.demoUrl, 'demoUrl'); + + // --- optional screenshots (max 2) — validate then upload to CDN --- + const validated = this.validateScreenshots(dto.screenshots); + const screenshotUrls = await this.uploadScreenshots(validated); + + // --- optional hackatime project names (validated against real projects) --- + const hackatimeProjectName: string[] = []; + if (dto.hackatimeProjectName && Array.isArray(dto.hackatimeProjectName) && dto.hackatimeProjectName.length > 0) { + // Cross-check the linked Hackatime account still belongs to this user + // before accepting any of its project names. + await this.hackatimeService.verifyAccountOwnership(hcaSub); + const realProjects = await this.hackatimeService.getProjectNames(hcaSub); + for (const raw of dto.hackatimeProjectName) { + if (typeof raw !== 'string') continue; + const cleaned = this.sanitize(raw).slice(0, 255); + if (!cleaned) continue; + if (!realProjects.includes(cleaned)) { + throw new BadRequestException( + `Hackatime project "${cleaned}" was not found on your account`, + ); + } + hackatimeProjectName.push(cleaned); + } + } + + const isUpdate = dto.isUpdate === true; + const otherHcProgram = this.validateOptionalString(dto.otherHcProgram, 'otherHcProgram', 255); + const aiUse = this.validateOptionalString(dto.aiUse, 'aiUse', 200); + + const project = this.projectRepo.create({ + userId, + name, + description, + projectType, + codeUrl, + readmeUrl, + demoUrl, + screenshot1Url: screenshotUrls[0], + screenshot2Url: screenshotUrls[1], + hackatimeProjectName, + isUpdate, + otherHcProgram, + aiUse, + }); + + const saved = await this.projectRepo.save(project); + + await this.auditLogService.log( + userId, + 'project_created', + `Created project "${name}"`, + impersonatorName, + ); + + // Sync DetailedProject funnel stage when a Hackatime project is linked + if (hackatimeProjectName.length > 0) { + this.userRepo.findOne({ where: { id: userId }, select: ['email'] }).then((u) => { + if (u?.email) this.rsvpService.updateDateField(u.email, 'Loops - beestDetailedProject'); + }); + } + + // Strip internal fields before returning to frontend + const { userId: _uid, user: _user, ...safe } = saved; + return safe; + } + + /** + * Returns the size of the review queue (all projects with status='unreviewed') + * and this project's position within it, where 1 = next to be reviewed. + * Position is derived from the project's latest 'unreviewed' submission's + * createdAt — that's the moment the project entered the queue. Projects + * with no submission row fall back to ranking last. + */ + async getQueuePosition(projectId: string, userId: string) { + const project = await this.projectRepo.findOne({ + where: { id: projectId, userId }, + select: ['id', 'status'], + }); + if (!project) throw new NotFoundException('Project not found'); + if (project.status !== 'unreviewed') { + throw new BadRequestException('Project is not awaiting review'); + } + + const total = await this.projectRepo.count({ where: { status: 'unreviewed' } }); + + const sub = await this.submissionRepo.findOne({ + where: { projectId, status: 'unreviewed' }, + order: { createdAt: 'DESC' }, + select: ['createdAt'], + }); + if (!sub) return { total, position: total }; + + const result: { count: number }[] = await this.projectRepo.query( + ` + SELECT COUNT(*)::int AS count + FROM projects p + WHERE p.status = 'unreviewed' + AND ( + SELECT MAX(s.created_at) + FROM submissions s + WHERE s.project_id = p.id AND s.status = 'unreviewed' + ) <= $1 + `, + [sub.createdAt], + ); + const position = Number(result[0]?.count ?? total); + return { total, position }; + } + + async findByUser(userId: string) { + const projects = await this.projectRepo.find({ + where: { userId }, + order: { createdAt: 'DESC' }, + select: [ + 'id', + 'name', + 'description', + 'projectType', + 'codeUrl', + 'readmeUrl', + 'demoUrl', + 'screenshot1Url', + 'screenshot2Url', + 'hackatimeProjectName', + 'status', + 'isUpdate', + 'otherHcProgram', + 'aiUse', + 'overrideHours', + 'pipesGranted', + 'createdAt', + 'updatedAt', + ], + }); + return projects; + } + + /** + * Returns the average time-to-first-review per project type, in seconds. + * Used by the public shipping guide so submitters know how long each + * project type usually takes to review. + * + * Common rejection reasons are not computed here — they are paraphrased by + * hand from a snapshot of feedback and embedded statically in the guide + * page so we never quote reviewers verbatim. + */ + async getReviewStats(): Promise< + { projectType: string; avgSeconds: number; sampleCount: number }[] + > { + const rows: { project_type: string; avg_seconds: string; sample_count: string }[] = + await this.projectRepo.query(` + WITH first_reviews AS ( + SELECT submission_id, MIN(created_at) AS first_review_at + FROM project_reviews + WHERE submission_id IS NOT NULL + GROUP BY submission_id + ) + SELECT + p.project_type AS project_type, + AVG(EXTRACT(EPOCH FROM (fr.first_review_at - s.created_at))) AS avg_seconds, + COUNT(*) AS sample_count + FROM first_reviews fr + JOIN submissions s ON s.id = fr.submission_id + JOIN projects p ON p.id = s.project_id + WHERE fr.first_review_at >= s.created_at + GROUP BY p.project_type + `); + + return rows.map((r) => ({ + projectType: r.project_type, + avgSeconds: Number(r.avg_seconds), + sampleCount: Number(r.sample_count), + })); + } + + /** + * Returns all approved projects with public-safe fields + hours. + */ + async findApprovedProjects(): Promise< + { + id: string; + name: string; + description: string; + projectType: string; + screenshot1Url: string | null; + screenshot2Url: string | null; + codeUrl: string | null; + demoUrl: string | null; + hours: number; + builderName: string; + }[] + > { + const projects = await this.projectRepo + .createQueryBuilder('project') + .innerJoinAndSelect('project.user', 'user') + .where('project.status = :status', { status: 'approved' }) + .select([ + 'project.id', + 'project.name', + 'project.description', + 'project.projectType', + 'project.screenshot1Url', + 'project.screenshot2Url', + 'project.codeUrl', + 'project.demoUrl', + 'project.hackatimeProjectName', + 'project.overrideHours', + 'project.createdAt', + 'user.hcaSub', + 'user.name', + 'user.nickname', + 'user.hackatimeToken', + ]) + .orderBy('project.createdAt', 'DESC') + .getMany(); + + const results = await Promise.allSettled( + projects.map(async (p) => { + const names = (p.hackatimeProjectName ?? []).filter((n) => !!n); + let hours = 0; + if (p.overrideHours != null) { + hours = p.overrideHours; + } else if (names.length > 0 && p.user.hackatimeToken) { + const result = await this.hackatimeService.getHoursForProjects( + p.user.hcaSub, + names, + ); + hours = result.hours; + } + return { + id: p.id, + name: p.name, + description: p.description, + projectType: p.projectType, + screenshot1Url: p.screenshot1Url, + screenshot2Url: p.screenshot2Url, + codeUrl: p.codeUrl, + demoUrl: p.demoUrl, + hours, + builderName: p.user.nickname || p.user.name || 'Anonymous', + }; + }), + ); + + return results + .filter( + (r): r is PromiseFulfilledResult => r.status === 'fulfilled', + ) + .map((r) => r.value); + } + + async userHasProjects(userId: string): Promise { + const count = await this.projectRepo.count({ where: { userId } }); + return count > 0; + } + + /** + * Returns approved projects grouped by user, including user name info. + * Only includes users who have a hackatime token (needed to fetch hours). + * Each entry exposes the per-project hackatime names and overrideHours so + * callers can compute approved (capped) hours, not raw Hackatime totals. + */ + async findApprovedProjectsGroupedByUser(): Promise< + Map< + string, + { + hcaSub: string; + name: string | null; + nickname: string | null; + projects: { hackatimeProjectNames: string[]; overrideHours: number }[]; + } + > + > { + const projects = await this.projectRepo + .createQueryBuilder('project') + .innerJoinAndSelect('project.user', 'user') + .where('project.status = :status', { status: 'approved' }) + .andWhere('user.hackatime_token IS NOT NULL') + .andWhere('project.hackatime_project_name IS NOT NULL') + .select([ + 'project.id', + 'project.hackatimeProjectName', + 'project.overrideHours', + 'user.id', + 'user.hcaSub', + 'user.name', + 'user.nickname', + ]) + .getMany(); + + const grouped = new Map< + string, + { + hcaSub: string; + name: string | null; + nickname: string | null; + projects: { hackatimeProjectNames: string[]; overrideHours: number }[]; + } + >(); + + for (const p of projects) { + const userId = p.user.id; + const names = [...new Set((p.hackatimeProjectName ?? []).filter((n) => !!n))]; + if (names.length === 0) continue; + + if (!grouped.has(userId)) { + grouped.set(userId, { + hcaSub: p.user.hcaSub, + name: p.user.name, + nickname: p.user.nickname, + projects: [], + }); + } + grouped.get(userId)!.projects.push({ + hackatimeProjectNames: names, + overrideHours: p.overrideHours ?? 0, + }); + } + + return grouped; + } + + async update( + projectId: string, + dto: UpdateProjectDto, + userId: string, + hcaSub: string, + impersonatorName?: string, + ) { + const project = await this.projectRepo.findOne({ + where: { id: projectId, userId }, + }); + if (!project) throw new NotFoundException('Project not found'); + + if (dto.name !== undefined) { + project.name = this.requireString(dto.name, 'name', 50); + } + if (dto.description !== undefined) { + project.description = this.requireString(dto.description, 'description', 300); + } + if (dto.projectType !== undefined) { + project.projectType = this.validateProjectType(dto.projectType); + } + if (dto.codeUrl !== undefined) { + project.codeUrl = dto.codeUrl === null ? null : this.validateUrl(dto.codeUrl, 'codeUrl'); + } + if (dto.readmeUrl !== undefined) { + project.readmeUrl = dto.readmeUrl === null ? null : this.validateUrl(dto.readmeUrl, 'readmeUrl'); + } + if (dto.demoUrl !== undefined) { + project.demoUrl = dto.demoUrl === null ? null : this.validateUrl(dto.demoUrl, 'demoUrl'); + } + if (dto.screenshots !== undefined) { + const validated = this.validateScreenshots(dto.screenshots); + const screenshotUrls = await this.uploadScreenshots(validated); + project.screenshot1Url = screenshotUrls[0] ?? null; + project.screenshot2Url = screenshotUrls[1] ?? null; + } + if (dto.hackatimeProjectName !== undefined) { + if (dto.hackatimeProjectName === null || (Array.isArray(dto.hackatimeProjectName) && dto.hackatimeProjectName.length === 0)) { + project.hackatimeProjectName = []; + } else if (Array.isArray(dto.hackatimeProjectName)) { + await this.hackatimeService.verifyAccountOwnership(hcaSub); + const realProjects = await this.hackatimeService.getProjectNames(hcaSub); + const validated: string[] = []; + for (const raw of dto.hackatimeProjectName) { + if (typeof raw !== 'string') continue; + const cleaned = this.sanitize(raw).slice(0, 255); + if (!cleaned) continue; + if (!realProjects.includes(cleaned)) { + throw new BadRequestException( + `Hackatime project "${cleaned}" was not found on your account`, + ); + } + validated.push(cleaned); + } + project.hackatimeProjectName = validated; + + // Sync DetailedProject funnel stage when a Hackatime project is linked + if (validated.length > 0) { + this.userRepo.findOne({ where: { id: userId }, select: ['email'] }).then((u) => { + if (u?.email) this.rsvpService.updateDateField(u.email, 'Loops - beestDetailedProject'); + }); + } + } + } + if (dto.isUpdate !== undefined) { + project.isUpdate = dto.isUpdate === true; + } + if (dto.otherHcProgram !== undefined) { + project.otherHcProgram = dto.otherHcProgram === null ? null : this.validateOptionalString(dto.otherHcProgram, 'otherHcProgram', 255); + } + if (dto.aiUse !== undefined) { + project.aiUse = dto.aiUse === null ? null : this.validateOptionalString(dto.aiUse, 'aiUse', 200); + } + if (dto.status !== undefined) { + if (dto.status === 'unreviewed') { + if (project.status !== 'unshipped' && project.status !== 'changes_needed') { + throw new BadRequestException('Invalid status transition'); + } + await this.requireShipEligibility(userId); + // Re-verify Hackatime account ownership at submit time, even if the + // linked names weren't touched in this request. Catches projects that + // were created before this guard existed. + if ((project.hackatimeProjectName ?? []).length > 0) { + await this.hackatimeService.verifyAccountOwnership(hcaSub); + } + project.status = 'unreviewed'; + } else if ( + dto.status === 'unshipped' && + project.status === 'unreviewed' + ) { + project.status = 'unshipped'; + } else { + throw new BadRequestException( + 'Invalid status transition', + ); + } + } + + const saved = await this.projectRepo.save(project); + + if (dto.status === 'unreviewed') { + // Create a submission record for this review request + const reviewerNote = this.validateOptionalString(dto.reviewerNote, 'reviewerNote', 1000); + const submission = this.submissionRepo.create({ + projectId: project.id, + userId, + changeDescription: null, + minHoursConfirmed: false, + reviewerNote, + status: 'unreviewed', + }); + await this.submissionRepo.save(submission); + + await this.auditLogService.log( + userId, + 'project_submitted', + `Submitted "${project.name}" for review`, + impersonatorName, + ); + + // Sync submission date to Airtable for Loops + this.userRepo.findOne({ where: { id: userId }, select: ['email'] }).then((u) => { + if (u?.email) this.rsvpService.updateDateField(u.email, 'Loops - beestShippedProject'); + }); + } else if (dto.status === 'unshipped') { + await this.auditLogService.log( + userId, + 'project_updated', + `Converted "${project.name}" back to draft`, + impersonatorName, + ); + } else { + await this.auditLogService.log( + userId, + 'project_updated', + `Updated project "${project.name}"`, + impersonatorName, + ); + } + + const { userId: _uid, user: _user, ...safe } = saved; + return safe; + } + + async delete(projectId: string, userId: string, impersonatorName?: string) { + const project = await this.projectRepo.findOne({ + where: { id: projectId, userId }, + }); + if (!project) throw new NotFoundException('Project not found'); + if (project.status === 'approved') { + throw new ForbiddenException('Approved projects cannot be deleted'); + } + + const name = project.name; + await this.projectRepo.remove(project); + + await this.auditLogService.log( + userId, + 'project_deleted', + `Deleted project "${name}"`, + impersonatorName, + ); + } + + /* ------------------------------------------------------------------ */ + /* Resubmit (approved → unreviewed with change description) */ + /* ------------------------------------------------------------------ */ + + async resubmit( + projectId: string, + userId: string, + hcaSub: string, + changeDescription: string, + minHoursConfirmed: boolean, + reviewerNote?: string | null, + impersonatorName?: string, + ) { + const project = await this.projectRepo.findOne({ + where: { id: projectId, userId }, + }); + if (!project) throw new NotFoundException('Project not found'); + if (project.status !== 'approved') { + throw new BadRequestException('Only approved projects can be resubmitted'); + } + + await this.requireShipEligibility(userId); + + // Validate inputs + const cleanDesc = this.requireString(changeDescription, 'changeDescription', 500); + if (!minHoursConfirmed) { + throw new BadRequestException('You must confirm at least 3 hours of work since the last ship'); + } + + // Verify at least 3 hours of new Hackatime work since last approval + const linkedNames = (project.hackatimeProjectName ?? []).filter((n) => !!n); + const previousApprovedHours = project.overrideHours ?? 0; + if (linkedNames.length > 0) { + await this.hackatimeService.verifyAccountOwnership(hcaSub); + try { + const { hours: currentHours } = await this.hackatimeService.getHoursForProjects( + hcaSub, + [...new Set(linkedNames)], + ); + const delta = currentHours - previousApprovedHours; + if (delta < 3) { + throw new BadRequestException( + `Only ${Math.round(delta * 10) / 10} new hours recorded since last approval. You need at least 3 hours of new work.`, + ); + } + } catch (err) { + if (err instanceof BadRequestException) throw err; + // If Hackatime lookup fails, let it through — reviewer will verify + this.logger.warn(`Hackatime hours check failed for resubmit: ${err}`); + } + } + + // Move project back to unreviewed, mark as update + project.status = 'unreviewed'; + project.isUpdate = true; + await this.projectRepo.save(project); + + // Create a submission record + const cleanReviewerNote = this.validateOptionalString(reviewerNote, 'reviewerNote', 1000); + const submission = this.submissionRepo.create({ + projectId: project.id, + userId, + changeDescription: cleanDesc, + minHoursConfirmed: true, + reviewerNote: cleanReviewerNote, + status: 'unreviewed', + }); + await this.submissionRepo.save(submission); + + await this.auditLogService.log( + userId, + 'project_submitted', + `Resubmitted "${project.name}" for review`, + impersonatorName, + ); + + return { success: true, submissionId: submission.id }; + } + + /* ------------------------------------------------------------------ */ + /* Submissions for a project */ + /* ------------------------------------------------------------------ */ + + async getSubmissions(projectId: string, userId: string) { + const project = await this.projectRepo.findOne({ + where: { id: projectId, userId }, + select: ['id'], + }); + if (!project) throw new NotFoundException('Project not found'); + + return this.submissionRepo.find({ + where: { projectId }, + order: { createdAt: 'DESC' }, + select: ['id', 'changeDescription', 'minHoursConfirmed', 'status', 'createdAt'], + }); + } + + /* ------------------------------------------------------------------ */ + /* Project detail (public, single approved project) */ + /* ------------------------------------------------------------------ */ + + async findApprovedProjectById(projectId: string) { + const project = await this.projectRepo + .createQueryBuilder('project') + .innerJoinAndSelect('project.user', 'user') + .where('project.id = :id', { id: projectId }) + .andWhere('project.status = :status', { status: 'approved' }) + .select([ + 'project.id', + 'project.name', + 'project.description', + 'project.projectType', + 'project.screenshot1Url', + 'project.screenshot2Url', + 'project.codeUrl', + 'project.demoUrl', + 'project.hackatimeProjectName', + 'project.overrideHours', + 'user.id', + 'user.hcaSub', + 'user.name', + 'user.nickname', + 'user.hackatimeToken', + ]) + .getOne(); + + if (!project) return null; + + const names = (project.hackatimeProjectName ?? []).filter((n) => !!n); + let hours = 0; + if (project.overrideHours != null) { + hours = project.overrideHours; + } else if (names.length > 0 && project.user.hackatimeToken) { + try { + const result = await this.hackatimeService.getHoursForProjects( + project.user.hcaSub, + names, + ); + hours = result.hours; + } catch { /* graceful fallback */ } + } + + return { + id: project.id, + name: project.name, + description: project.description, + projectType: project.projectType, + screenshot1Url: project.screenshot1Url, + screenshot2Url: project.screenshot2Url, + codeUrl: project.codeUrl, + demoUrl: project.demoUrl, + hours, + builderName: project.user.nickname || project.user.name || 'Anonymous', + ownerId: project.user.id, + }; + } + + /* ------------------------------------------------------------------ */ + /* Comments */ + /* ------------------------------------------------------------------ */ + + async getComments(projectId: string) { + const comments = await this.commentRepo.find({ + where: { projectId }, + order: { createdAt: 'ASC' }, + relations: ['user'], + }); + + return comments.map((c) => ({ + id: c.id, + body: c.body, + authorName: c.user?.nickname || c.user?.name || 'Anonymous', + authorId: c.userId, + createdAt: c.createdAt, + })); + } + + async addComment(projectId: string, userId: string, body: string) { + // Verify the project exists and is approved + const project = await this.projectRepo.findOne({ + where: { id: projectId, status: 'approved' }, + select: ['id'], + }); + if (!project) throw new NotFoundException('Project not found'); + + // Sanitize and validate + const clean = this.sanitize(body).slice(0, 500); + if (clean.length === 0) { + throw new BadRequestException('Comment cannot be empty'); + } + + const comment = this.commentRepo.create({ + projectId, + userId, + body: clean, + }); + const saved = await this.commentRepo.save(comment); + + return { id: saved.id, body: saved.body, createdAt: saved.createdAt }; + } + + async deleteComment(commentId: string, userId: string, userEmail: string) { + const comment = await this.commentRepo.findOne({ + where: { id: commentId }, + relations: ['project'], + }); + if (!comment) throw new NotFoundException('Comment not found'); + + // Allow deletion by: comment author, project owner, or admin + const isAuthor = comment.userId === userId; + const isProjectOwner = comment.project?.userId === userId; + + if (!isAuthor && !isProjectOwner) { + // Check if user is admin + const perms = await this.rsvpService.getPerms(userEmail); + const isAdmin = perms && ['Super Admin', 'Reviewer', 'Fraud Reviewer'].includes(perms); + if (!isAdmin) { + throw new ForbiddenException('Not allowed to delete this comment'); + } + } + + await this.commentRepo.remove(comment); + return { deleted: true }; + } + + /** + * Backend gate for shipping a project. Live call to identity.hackclub.com so a + * freshly verified user can ship without logging out and back in. + * + * Address/birthdate are intentionally NOT enforced here: an identity-verified + * user has a birthdate on file by construction, and missing addresses get + * caught at fulfillment. The frontend still surfaces the soft prompt for both. + */ + private async requireShipEligibility(userId: string): Promise { + const user = await this.userRepo.findOne({ + where: { id: userId }, + select: ['email', 'slackId'], + }); + const verified = await this.identityService.isVerified({ + slackId: user?.slackId, + email: user?.email, + }); + if (!verified) { + throw new ForbiddenException( + 'Verify your identity at https://auth.hackclub.com/verifications/document before shipping a project.', + ); + } + } + + /* ------------------------------------------------------------------ */ + /* Sanitisation helpers */ + /* ------------------------------------------------------------------ */ + + /** + * Strips characters that could be used for HTML/SQL/script injection. + * The result is treated as a plain-text string. + */ + private sanitize(raw: string): string { + return String(raw) + .replace(/[<>"'`&\\]/g, '') // strip injection-relevant chars + .replace(/\0/g, '') // strip null bytes + .trim(); + } + + private requireString( + value: unknown, + field: string, + maxLen: number, + ): string { + if (!value || typeof value !== 'string') { + throw new BadRequestException(`${field} is required`); + } + const clean = this.sanitize(value).slice(0, maxLen); + if (clean.length === 0) { + throw new BadRequestException(`${field} is required`); + } + return clean; + } + + private validateOptionalString( + value: unknown, + field: string, + maxLen: number, + ): string | null { + if (!value || typeof value !== 'string') return null; + const clean = this.sanitize(value).slice(0, maxLen); + return clean.length === 0 ? null : clean; + } + + private validateProjectType(value: unknown): string { + if (!value || typeof value !== 'string') { + throw new BadRequestException('projectType is required'); + } + const v = value.trim().toLowerCase(); + if (!(VALID_PROJECT_TYPES as readonly string[]).includes(v)) { + throw new BadRequestException( + `projectType must be one of: ${VALID_PROJECT_TYPES.join(', ')}`, + ); + } + return v; + } + + /* ------------------------------------------------------------------ */ + /* URL validation */ + /* ------------------------------------------------------------------ */ + + /** Matches private/reserved IP ranges and localhost. */ + private static readonly BLOCKED_HOSTS = + /^(localhost|127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|169\.254\.|0\.|::1$|fc|fd|\[::1\])/i; + + private validateUrl( + value: string | undefined, + field: string, + ): string | null { + if (!value || typeof value !== 'string') return null; + const trimmed = value.trim(); + if (trimmed.length === 0) return null; + + if (!trimmed.startsWith('https://')) { + throw new BadRequestException( + `${field} must start with https:// — please prepend it to your link`, + ); + } + + if (trimmed.length > 2048) { + throw new BadRequestException(`${field} is too long (max 2048 chars)`); + } + + try { + const parsed = new URL(trimmed); + if (parsed.protocol !== 'https:') { + throw new Error(); + } + if (ProjectsService.BLOCKED_HOSTS.test(parsed.hostname)) { + throw new Error('Internal URL'); + } + } catch { + throw new BadRequestException(`${field} is not a valid URL`); + } + + return trimmed; + } + + /* ------------------------------------------------------------------ */ + /* Screenshot validation & CDN upload */ + /* ------------------------------------------------------------------ */ + + /** + * Validates base64 data URIs: checks MIME type, magic bytes, and size. + * Returns an array of { mime, buffer } for valid screenshots. + */ + private validateScreenshots( + screenshots: string[] | undefined, + ): { mime: string; buffer: Buffer }[] { + if (!screenshots || !Array.isArray(screenshots)) return []; + + const items = screenshots.slice(0, 2); + const results: { mime: string; buffer: Buffer }[] = []; + + for (let i = 0; i < items.length; i++) { + const raw = items[i]; + if (!raw || typeof raw !== 'string') continue; + + // Expect data URI format: data:image/...;base64,... + const match = raw.match( + /^data:(image\/(?:png|jpeg|gif|webp));base64,(.+)$/, + ); + if (!match) { + throw new BadRequestException( + `Screenshot ${i + 1} must be a PNG, JPEG, GIF, or WebP image`, + ); + } + + const declaredMime = match[1]; + const b64Data = match[2]; + + // Decode to check real size and magic bytes + const buffer = Buffer.from(b64Data, 'base64'); + + // Verify magic bytes match declared MIME type + const sig = IMAGE_SIGNATURES.find((s) => s.mime === declaredMime); + if (!sig || !b64Data.startsWith(sig.b64Prefix)) { + throw new BadRequestException( + `Screenshot ${i + 1} content does not match its declared type (${declaredMime})`, + ); + } + + results.push({ mime: declaredMime, buffer }); + } + + return results; + } + + /** + * Uploads validated screenshot buffers to the Hack Club CDN. + * Returns [url1 | null, url2 | null]. + */ + private async uploadScreenshots( + items: { mime: string; buffer: Buffer }[], + ): Promise<[string | null, string | null]> { + const urls: [string | null, string | null] = [null, null]; + + for (let i = 0; i < items.length; i++) { + const { mime, buffer } = items[i]; + const ext = MIME_EXTENSIONS[mime] ?? 'bin'; + const filename = `screenshot-${Date.now()}-${i + 1}.${ext}`; + + const blob = new Blob([new Uint8Array(buffer)], { type: mime }); + const formData = new FormData(); + formData.append('file', blob, filename); + + let res: Response; + try { + res = await fetchWithTimeout(CDN_UPLOAD_URL, { + method: 'POST', + headers: { Authorization: `Bearer ${this.cdnApiKey}` }, + body: formData, + }); + } catch (err) { + this.logger.error(`CDN upload network error for screenshot ${i + 1}: ${err}`); + throw new BadRequestException( + `Screenshot upload failed — the CDN is unreachable. Try again without a screenshot.`, + ); + } + + if (!res.ok) { + const err = await res.text().catch(() => ''); + this.logger.error(`CDN upload failed (${res.status}): ${err}`); + throw new BadRequestException( + `Failed to upload screenshot ${i + 1}. Please try again.`, + ); + } + + const data = await res.json().catch(() => null); + if (!data?.url) { + this.logger.error(`CDN upload returned no URL for screenshot ${i + 1}`); + throw new BadRequestException( + `Failed to upload screenshot ${i + 1}. Please try again.`, + ); + } + urls[i] = data.url; + } + + return urls; + } +} diff --git a/backend/src/projects/update-project.dto.ts b/backend/src/projects/update-project.dto.ts new file mode 100644 index 0000000..a31daba --- /dev/null +++ b/backend/src/projects/update-project.dto.ts @@ -0,0 +1,15 @@ +export class UpdateProjectDto { + name?: string; + description?: string; + projectType?: string; + codeUrl?: string | null; + readmeUrl?: string | null; + demoUrl?: string | null; + screenshots?: string[]; + hackatimeProjectName?: string[] | null; + isUpdate?: boolean; + otherHcProgram?: string | null; + aiUse?: string | null; + status?: string; + reviewerNote?: string | null; +} diff --git a/backend/src/rsvp/rsvp.module.ts b/backend/src/rsvp/rsvp.module.ts new file mode 100644 index 0000000..02e7d97 --- /dev/null +++ b/backend/src/rsvp/rsvp.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { RsvpService } from './rsvp.service'; + +@Module({ + providers: [RsvpService], + exports: [RsvpService], +}) +export class RsvpModule {} diff --git a/backend/src/rsvp/rsvp.service.ts b/backend/src/rsvp/rsvp.service.ts new file mode 100644 index 0000000..66f3e65 --- /dev/null +++ b/backend/src/rsvp/rsvp.service.ts @@ -0,0 +1,364 @@ +import { + Injectable, + BadRequestException, + HttpException, + HttpStatus, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { fetchWithTimeout } from '../fetch.util'; + +const EMAIL_RE = + /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; + +@Injectable() +export class RsvpService { + private readonly pendingEmails = new Set(); + private readonly airtableApiKey: string; + private readonly airtableBaseId: string; + private readonly airtableTableName: string; + + constructor(private readonly config: ConfigService) { + this.airtableApiKey = this.config.getOrThrow('AIRTABLE_API_KEY'); + this.airtableBaseId = this.config.getOrThrow('AIRTABLE_BASE_ID'); + this.airtableTableName = this.config.getOrThrow( + 'AIRTABLE_TABLE_NAME', + ); + } + + private sanitizeEmail(raw: string): string { + return raw.trim().slice(0, 254).replace(/[<>"'&\\]/g, ''); + } + + /** + * Escapes a string for safe use inside an Airtable filterByFormula value. + * Doubles any backslashes first, then escapes double-quotes. + * The value is wrapped in double-quotes by the caller. + */ + private escapeAirtableValue(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + } + + private get baseUrl(): string { + return `https://api.airtable.com/v0/${this.airtableBaseId}/${encodeURIComponent(this.airtableTableName)}`; + } + + async createRsvp(rawEmail: string): Promise<{ success: true; existing: boolean }> { + const email = this.sanitizeEmail(rawEmail); + + if (!EMAIL_RE.test(email)) { + throw new BadRequestException('Invalid email address'); + } + + if (this.pendingEmails.has(email)) { + throw new HttpException('RSVP already in progress', HttpStatus.TOO_MANY_REQUESTS); + } + this.pendingEmails.add(email); + + try { + const existing = await this.checkExisting(email); + if (existing) { + return { success: true, existing: true }; + } + + await this.createRecord(email); + return { success: true, existing: false }; + } finally { + this.pendingEmails.delete(email); + } + } + + private async checkExisting(email: string): Promise { + const searchParams = new URLSearchParams({ + filterByFormula: `{Email} = "${this.escapeAirtableValue(email)}"`, + maxRecords: '1', + }); + + const res = await fetchWithTimeout(`${this.baseUrl}?${searchParams}`, { + headers: { Authorization: `Bearer ${this.airtableApiKey}` }, + }); + + if (!res.ok) { + const text = await res.text(); + console.error('Airtable lookup error:', res.status, text); + throw new HttpException('Failed to check RSVP', HttpStatus.BAD_GATEWAY); + } + + const data = await res.json(); + return data.records?.length > 0; + } + + async findRecordIdByEmail(rawEmail: string): Promise { + const email = this.sanitizeEmail(rawEmail); + const searchParams = new URLSearchParams({ + filterByFormula: `{Email} = "${this.escapeAirtableValue(email)}"`, + maxRecords: '1', + }); + + const res = await fetchWithTimeout(`${this.baseUrl}?${searchParams}`, { + headers: { Authorization: `Bearer ${this.airtableApiKey}` }, + }); + + if (!res.ok) { + throw new HttpException('Failed to find Airtable record', HttpStatus.BAD_GATEWAY); + } + + const data = await res.json(); + return data.records?.[0]?.id ?? null; + } + + async updatePerms(rawEmail: string, perms: string): Promise { + const recordId = await this.findRecordIdByEmail(rawEmail); + if (!recordId) { + throw new HttpException('User not found in Airtable', HttpStatus.NOT_FOUND); + } + + const res = await fetchWithTimeout(`${this.baseUrl}/${recordId}`, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${this.airtableApiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ fields: { Perms: perms } }), + }); + + if (!res.ok) { + const text = await res.text(); + console.error('Airtable update error:', res.status, text); + throw new HttpException('Failed to update permissions', HttpStatus.BAD_GATEWAY); + } + } + + /** Clears the Perms field. Pairs with getPerms() — `null` perms ≠ Banned. */ + async clearPerms(rawEmail: string): Promise { + await this.updatePerms(rawEmail, ''); + } + + /** Records the home "hackathon or shop?" answer onto the user's RSVP record. */ + async setIntent(rawEmail: string, intent: string): Promise { + const recordId = await this.findRecordIdByEmail(rawEmail); + if (!recordId) { + throw new HttpException('User not found in Airtable', HttpStatus.NOT_FOUND); + } + + const res = await fetchWithTimeout(`${this.baseUrl}/${recordId}`, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${this.airtableApiKey}`, + 'Content-Type': 'application/json', + }, + // typecast lets Airtable create the single-select option if it doesn't + // exist yet (e.g. the new "Browsing"/"Both" choices). + body: JSON.stringify({ typecast: true, fields: { Intent: intent } }), + }); + + if (!res.ok) { + const text = await res.text(); + console.error('Airtable intent update error:', res.status, text); + throw new HttpException('Failed to record intent', HttpStatus.BAD_GATEWAY); + } + } + + async getPerms(rawEmail: string): Promise { + const email = this.sanitizeEmail(rawEmail); + const searchParams = new URLSearchParams({ + filterByFormula: `{Email} = "${this.escapeAirtableValue(email)}"`, + maxRecords: '1', + }); + searchParams.append('fields[]', 'Perms'); + + const res = await fetchWithTimeout(`${this.baseUrl}?${searchParams}`, { + headers: { Authorization: `Bearer ${this.airtableApiKey}` }, + }); + + if (!res.ok) { + throw new HttpException( + 'Failed to check permissions', + HttpStatus.BAD_GATEWAY, + ); + } + + const data = await res.json(); + return data.records?.[0]?.fields?.Perms ?? null; + } + + /** + * Fetches the Airtable `createdTime` of every RSVP/user record, sorted + * ascending. + * + * SAFETY: this method is the sole Airtable data source for admin stats + * (sign-ups chart + funnel). It must never pull records from any table + * other than the RSVP/user table — not Projects, not Approved Projects, + * not anything in the unified base. The allowed URL prefix is frozen on + * entry and every paged fetch is asserted against it. + */ + async getAllSignupTimestamps(): Promise { + const allowedPrefix = `https://api.airtable.com/v0/${this.airtableBaseId}/${encodeURIComponent(this.airtableTableName)}`; + + const timestamps: string[] = []; + let offset: string | undefined; + + do { + const searchParams = new URLSearchParams(); + searchParams.append('pageSize', '100'); + searchParams.append('fields[]', 'Email'); // limit payload — we only use createdTime + if (offset) searchParams.append('offset', offset); + + const url = `${this.baseUrl}?${searchParams}`; + if (!url.startsWith(`${allowedPrefix}?`)) { + throw new HttpException( + 'Sign-up stats fetch blocked: URL does not target the RSVP/user table', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + const res = await fetchWithTimeout(url, { + headers: { Authorization: `Bearer ${this.airtableApiKey}` }, + }); + + if (!res.ok) break; + + const data = await res.json(); + for (const record of data.records ?? []) { + if (record.createdTime) timestamps.push(record.createdTime); + } + offset = data.offset; + } while (offset); + + timestamps.sort(); + return timestamps; + } + + async getAllPerms(): Promise> { + const permsMap = new Map(); + let offset: string | undefined; + + do { + const searchParams = new URLSearchParams(); + searchParams.append('fields[]', 'Email'); + searchParams.append('fields[]', 'Perms'); + searchParams.append('pageSize', '100'); + if (offset) searchParams.append('offset', offset); + + const res = await fetchWithTimeout(`${this.baseUrl}?${searchParams}`, { + headers: { Authorization: `Bearer ${this.airtableApiKey}` }, + }); + + if (!res.ok) break; + + const data = await res.json(); + for (const record of data.records ?? []) { + const email = record.fields?.Email; + const perms = record.fields?.Perms; + if (email) permsMap.set(email.toLowerCase(), perms ?? 'User'); + } + offset = data.offset; + } while (offset); + + return permsMap; + } + + /** + * Sets a date field in Airtable for Loops sync, only if not already set. + * Fire-and-forget — logs errors but never throws. + */ + async updateDateField(rawEmail: string, fieldName: string): Promise { + try { + const email = this.sanitizeEmail(rawEmail); + const searchParams = new URLSearchParams({ + filterByFormula: `{Email} = "${this.escapeAirtableValue(email)}"`, + maxRecords: '1', + }); + searchParams.append('fields[]', fieldName); + + const lookupRes = await fetchWithTimeout(`${this.baseUrl}?${searchParams}`, { + headers: { Authorization: `Bearer ${this.airtableApiKey}` }, + }); + if (!lookupRes.ok) return; + + const data = await lookupRes.json(); + const record = data.records?.[0]; + if (!record) return; + + // Skip if the field already has a value + if (record.fields?.[fieldName]) return; + + const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD + const res = await fetchWithTimeout(`${this.baseUrl}/${record.id}`, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${this.airtableApiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ fields: { [fieldName]: today } }), + }); + + if (!res.ok) { + const text = await res.text(); + console.error(`Airtable updateDateField(${fieldName}) error:`, res.status, text); + } + } catch (err) { + console.error(`Airtable updateDateField(${fieldName}) failed:`, err); + } + } + + + async createApprovedProjectRecord(fields: Record): Promise { + const tableName = this.config.get('AIRTABLE_PROJECTS_TABLE_NAME', 'Projects'); + const url = `https://api.airtable.com/v0/${this.airtableBaseId}/${encodeURIComponent(tableName)}`; + const res = await fetchWithTimeout(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.airtableApiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ records: [{ fields }] }), + }); + if (!res.ok) { + const text = await res.text(); + console.error('Airtable createApprovedProjectRecord error:', res.status, text); + throw new Error(`Airtable API error (${res.status}): ${text}`); + } + } + + async getStickerLink(rawEmail: string): Promise { + const email = this.sanitizeEmail(rawEmail); + const searchParams = new URLSearchParams({ + filterByFormula: `{Email} = "${this.escapeAirtableValue(email)}"`, + maxRecords: '1', + }); + searchParams.append('fields[]', 'Fillout Sticker Link'); + + const res = await fetchWithTimeout(`${this.baseUrl}?${searchParams}`, { + headers: { Authorization: `Bearer ${this.airtableApiKey}` }, + }); + + if (!res.ok) { + throw new HttpException( + 'Failed to fetch sticker link', + HttpStatus.BAD_GATEWAY, + ); + } + + const data = await res.json(); + return data.records?.[0]?.fields?.['Fillout Sticker Link'] ?? null; + } + + private async createRecord(email: string): Promise { + const res = await fetchWithTimeout(this.baseUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.airtableApiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + records: [{ fields: { Email: email } }], + }), + }); + + if (!res.ok) { + const text = await res.text(); + console.error('Airtable error:', res.status, text); + throw new HttpException('Failed to save RSVP', HttpStatus.BAD_GATEWAY); + } + } +} diff --git a/backend/src/shop/shop.controller.ts b/backend/src/shop/shop.controller.ts new file mode 100644 index 0000000..447e884 --- /dev/null +++ b/backend/src/shop/shop.controller.ts @@ -0,0 +1,143 @@ +import { + Controller, + Get, + Post, + Delete, + Body, + Param, + ParseUUIDPipe, + Req, + UseGuards, + BadRequestException, +} from '@nestjs/common'; +import { Throttle } from '@nestjs/throttler'; +import type { Request } from 'express'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { ShopService } from './shop.service'; + +@Controller('api/shop') +export class ShopController { + constructor(private readonly shopService: ShopService) {} + + @UseGuards(JwtAuthGuard) + @Get() + async list() { + return this.shopService.listActive(); + } + + @UseGuards(JwtAuthGuard) + @Post('purchase') + async purchase( + @Req() req: Request, + @Body() body: { shopItemId?: string; quantity?: number }, + ) { + const userId = (req as any).user?.uid; + if (!userId) throw new BadRequestException('Not authenticated'); + if (!body.shopItemId || typeof body.shopItemId !== 'string') { + throw new BadRequestException('shopItemId is required'); + } + if (body.quantity === undefined || !Number.isInteger(body.quantity) || body.quantity < 1) { + throw new BadRequestException('quantity must be a positive integer'); + } + return this.shopService.purchase(userId, body.shopItemId, body.quantity); + } + + @UseGuards(JwtAuthGuard) + @Get('pipes') + async getPipes(@Req() req: Request) { + const userId = (req as any).user?.uid; + if (!userId) throw new BadRequestException('Not authenticated'); + const pipes = await this.shopService.getPipes(userId); + return { pipes }; + } + + @UseGuards(JwtAuthGuard) + @Get('orders') + async getOrders(@Req() req: Request) { + const userId = (req as any).user?.uid; + if (!userId) throw new BadRequestException('Not authenticated'); + return this.shopService.getUserOrders(userId); + } + + @UseGuards(JwtAuthGuard) + @Post('orders/:id/refund') + async refundOwnOrder( + @Req() req: Request, + @Param('id', ParseUUIDPipe) id: string, + ) { + const userId = (req as any).user?.uid; + if (!userId) throw new BadRequestException('Not authenticated'); + return this.shopService.refundOrder(id, { + requireUserId: userId, + requirePending: true, + }); + } + + @UseGuards(JwtAuthGuard) + @Get('fulfillment') + async getFulfillmentUpdates(@Req() req: Request) { + const userId = (req as any).user?.uid; + if (!userId) throw new BadRequestException('Not authenticated'); + return this.shopService.getUserFulfillmentUpdates(userId); + } + + @UseGuards(JwtAuthGuard) + @Post('fulfillment/read') + async markRead(@Req() req: Request) { + const userId = (req as any).user?.uid; + if (!userId) throw new BadRequestException('Not authenticated'); + await this.shopService.markUpdatesRead(userId); + return { success: true }; + } + + @UseGuards(JwtAuthGuard) + @Get('fulfillment/unread') + async getUnreadCount(@Req() req: Request) { + const userId = (req as any).user?.uid; + if (!userId) throw new BadRequestException('Not authenticated'); + const count = await this.shopService.getUnreadCount(userId); + return { count }; + } + + // ── Shop suggestions ── + + @UseGuards(JwtAuthGuard) + @Get('suggestions') + async listSuggestions(@Req() req: Request) { + const userId = (req as any).user?.uid; + if (!userId) throw new BadRequestException('Not authenticated'); + return this.shopService.listSuggestions(userId); + } + + @Throttle({ default: { limit: 10, ttl: 60000 } }) + @UseGuards(JwtAuthGuard) + @Post('suggestions') + async createSuggestion( + @Req() req: Request, + @Body() body: { text?: string }, + ) { + const userId = (req as any).user?.uid; + if (!userId) throw new BadRequestException('Not authenticated'); + if (!body?.text || typeof body.text !== 'string') { + throw new BadRequestException('text is required'); + } + return this.shopService.createSuggestion(userId, body.text); + } + + @Throttle({ default: { limit: 60, ttl: 60000 } }) + @UseGuards(JwtAuthGuard) + @Post('suggestions/:id/vote') + async voteSuggestion(@Req() req: Request, @Param('id') id: string) { + const userId = (req as any).user?.uid; + if (!userId) throw new BadRequestException('Not authenticated'); + return this.shopService.toggleSuggestionVote(userId, id); + } + + @UseGuards(JwtAuthGuard) + @Delete('suggestions/:id') + async deleteSuggestion(@Req() req: Request, @Param('id') id: string) { + const userId = (req as any).user?.uid; + if (!userId) throw new BadRequestException('Not authenticated'); + return this.shopService.deleteSuggestion(userId, id); + } +} diff --git a/backend/src/shop/shop.module.ts b/backend/src/shop/shop.module.ts new file mode 100644 index 0000000..91e27fb --- /dev/null +++ b/backend/src/shop/shop.module.ts @@ -0,0 +1,26 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthModule } from '../auth/auth.module'; +import { AuditLogModule } from '../audit-log/audit-log.module'; +import { RsvpModule } from '../rsvp/rsvp.module'; +import { ShopItem } from '../entities/shop-item.entity'; +import { Order } from '../entities/order.entity'; +import { FulfillmentUpdate } from '../entities/fulfillment-update.entity'; +import { User } from '../entities/user.entity'; +import { ShopSuggestion } from '../entities/shop-suggestion.entity'; +import { ShopSuggestionVote } from '../entities/shop-suggestion-vote.entity'; +import { ShopController } from './shop.controller'; +import { ShopService } from './shop.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ShopItem, Order, FulfillmentUpdate, User, ShopSuggestion, ShopSuggestionVote]), + AuthModule, + AuditLogModule, + RsvpModule, + ], + controllers: [ShopController], + providers: [ShopService], + exports: [ShopService], +}) +export class ShopModule {} diff --git a/backend/src/shop/shop.service.ts b/backend/src/shop/shop.service.ts new file mode 100644 index 0000000..04638c2 --- /dev/null +++ b/backend/src/shop/shop.service.ts @@ -0,0 +1,621 @@ +import { + Injectable, + BadRequestException, + ForbiddenException, + NotFoundException, + ConflictException, + Logger, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, DataSource, In } from 'typeorm'; +import { ShopItem } from '../entities/shop-item.entity'; +import { Order } from '../entities/order.entity'; +import { FulfillmentUpdate } from '../entities/fulfillment-update.entity'; +import { User } from '../entities/user.entity'; +import { ShopSuggestion } from '../entities/shop-suggestion.entity'; +import { ShopSuggestionVote } from '../entities/shop-suggestion-vote.entity'; +import { AuditLogService } from '../audit-log/audit-log.service'; +import { RsvpService } from '../rsvp/rsvp.service'; + +@Injectable() +export class ShopService { + private readonly logger = new Logger(ShopService.name); + + constructor( + @InjectRepository(ShopItem) + private readonly shopRepo: Repository, + @InjectRepository(Order) + private readonly orderRepo: Repository, + @InjectRepository(FulfillmentUpdate) + private readonly fulfillmentRepo: Repository, + @InjectRepository(User) + private readonly userRepo: Repository, + @InjectRepository(ShopSuggestion) + private readonly suggestionRepo: Repository, + @InjectRepository(ShopSuggestionVote) + private readonly suggestionVoteRepo: Repository, + private readonly dataSource: DataSource, + private readonly auditLogService: AuditLogService, + private readonly rsvpService: RsvpService, + ) {} + + // ── Shop suggestions ── + + async listSuggestions(userId: string) { + const rows = await this.suggestionRepo + .createQueryBuilder('s') + .leftJoin('s.user', 'u') + .leftJoin( + ShopSuggestionVote, + 'v', + 'v.suggestion_id = s.id', + ) + .leftJoin( + ShopSuggestionVote, + 'mv', + 'mv.suggestion_id = s.id AND mv.user_id = :userId', + { userId }, + ) + .select('s.id', 'id') + .addSelect('s.text', 'text') + .addSelect('s.created_at', 'createdAt') + .addSelect('s.user_id', 'userId') + .addSelect('COALESCE(u.nickname, u.name)', 'authorName') + .addSelect('COUNT(DISTINCT v.id)::int', 'voteCount') + .addSelect('BOOL_OR(mv.id IS NOT NULL)', 'votedByUser') + .groupBy('s.id') + .addGroupBy('u.nickname') + .addGroupBy('u.name') + .orderBy('"voteCount"', 'DESC') + .addOrderBy('s.created_at', 'DESC') + .getRawMany(); + + return rows.map((r) => ({ + id: r.id, + text: r.text, + createdAt: r.createdAt, + authorName: r.authorName ?? 'Someone', + isMine: r.userId === userId, + voteCount: Number(r.voteCount ?? 0), + votedByUser: !!r.votedByUser, + })); + } + + async createSuggestion(userId: string, text: string) { + // Strip NUL + HTML tag delimiters as defense-in-depth (current render path + // auto-escapes via Svelte, but a future {@html} or out-of-band surface — + // admin panel, email, CSV — would otherwise execute stored payloads). + // Collapse whitespace runs to keep the layout sane. + const clean = text + .replace(/\0/g, '') + .replace(/[<>]/g, '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 200); + if (!clean) { + throw new BadRequestException('Suggestion cannot be empty'); + } + + // Rate limit: max 5 suggestions per user per day + const since = new Date(Date.now() - 24 * 60 * 60 * 1000); + const recent = await this.suggestionRepo + .createQueryBuilder('s') + .where('s.user_id = :userId', { userId }) + .andWhere('s.created_at > :since', { since }) + .getCount(); + if (recent >= 5) { + throw new BadRequestException( + 'You can suggest up to 5 items per day. Try again tomorrow!', + ); + } + + const suggestion = this.suggestionRepo.create({ userId, text: clean }); + const saved = await this.suggestionRepo.save(suggestion); + + // Auto-upvote your own suggestion + try { + const vote = this.suggestionVoteRepo.create({ + userId, + suggestionId: saved.id, + }); + await this.suggestionVoteRepo.save(vote); + } catch { + // ignore unique violation race + } + + return { id: saved.id }; + } + + /** Toggle vote: adds an upvote if missing, removes if present. */ + async toggleSuggestionVote(userId: string, suggestionId: string) { + const suggestion = await this.suggestionRepo.findOne({ + where: { id: suggestionId }, + }); + if (!suggestion) throw new NotFoundException('Suggestion not found'); + + const existing = await this.suggestionVoteRepo.findOne({ + where: { userId, suggestionId }, + }); + if (existing) { + await this.suggestionVoteRepo.remove(existing); + const count = await this.suggestionVoteRepo.count({ + where: { suggestionId }, + }); + return { votedByUser: false, voteCount: count }; + } + + try { + const vote = this.suggestionVoteRepo.create({ userId, suggestionId }); + await this.suggestionVoteRepo.save(vote); + } catch (err: any) { + if (err?.code !== '23505') throw err; + // race — already voted + } + const count = await this.suggestionVoteRepo.count({ + where: { suggestionId }, + }); + return { votedByUser: true, voteCount: count }; + } + + async deleteSuggestion(userId: string, suggestionId: string) { + const suggestion = await this.suggestionRepo.findOne({ + where: { id: suggestionId }, + }); + if (!suggestion) throw new NotFoundException('Suggestion not found'); + if (suggestion.userId !== userId) { + throw new BadRequestException('You can only delete your own suggestions'); + } + await this.suggestionRepo.remove(suggestion); + return { success: true }; + } + + async listActive() { + return this.shopRepo.find({ + where: { isActive: true }, + order: { isFeatured: 'DESC', priceHours: 'ASC' }, + select: ['id', 'name', 'description', 'detailedDescription', 'imageUrl', 'priceHours', 'stock', 'sortOrder', 'isFeatured', 'estimatedShip'], + }); + } + + /** + * Purchase a shop item. Uses a serializable transaction with pessimistic + * locking on both the user row and the shop item row to prevent race + * conditions (double-spend, overselling). + */ + async purchase(userId: string, shopItemId: string, quantity: number) { + // Validate quantity upfront + if (!Number.isInteger(quantity) || quantity < 1) { + throw new BadRequestException('Quantity must be a positive integer'); + } + if (quantity > 100) { + throw new BadRequestException('Maximum quantity per order is 100'); + } + + return this.dataSource.transaction('SERIALIZABLE', async (manager) => { + // Lock the user row + const user = await manager.findOne(User, { + where: { id: userId }, + lock: { mode: 'pessimistic_write' }, + }); + if (!user) throw new NotFoundException('User not found'); + + // Lock the shop item row + const item = await manager.findOne(ShopItem, { + where: { id: shopItemId, isActive: true }, + lock: { mode: 'pessimistic_write' }, + }); + if (!item) throw new NotFoundException('Shop item not found or inactive'); + + // Check stock + if (item.stock !== null) { + if (item.stock < quantity) { + throw new ConflictException( + item.stock === 0 + ? 'This item is out of stock' + : `Only ${item.stock} remaining`, + ); + } + } + + // Check budget (pipes) + const totalCost = item.priceHours * quantity; + if (user.pipes < totalCost) { + throw new BadRequestException( + `Not enough Pipes. You have ${user.pipes}, need ${totalCost}`, + ); + } + + // Deduct pipes + user.pipes -= totalCost; + await manager.save(User, user); + + // Deduct stock if limited + if (item.stock !== null) { + item.stock -= quantity; + // If stock hits 0, deactivate the item + if (item.stock <= 0) { + item.isActive = false; + } + await manager.save(ShopItem, item); + } + + // Create order + const order = manager.create(Order, { + userId, + shopItemId: item.id, + quantity, + pipesSpent: totalCost, + itemName: item.name, + status: 'pending', + }); + const savedOrder = await manager.save(Order, order); + + // Create fulfillment update + const update = manager.create(FulfillmentUpdate, { + userId, + orderId: savedOrder.id, + message: 'Hey! we got the order - I\'ll keep you updated on when I get it fulfilled.', + isRead: false, + }); + await manager.save(FulfillmentUpdate, update); + + return { + orderId: savedOrder.id, + itemName: item.name, + quantity, + pipesSpent: totalCost, + remainingPipes: user.pipes, + }; + }).then(async (result) => { + // Audit log outside the transaction + await this.auditLogService.log( + userId, + 'shop_purchase', + `Purchased ${result.quantity}x ${result.itemName} for ${result.pipesSpent} Pipes`, + ); + + // Sync purchase date to Airtable for Loops + this.userRepo.findOne({ where: { id: userId }, select: ['email'] }).then((u) => { + if (u?.email) this.rsvpService.updateDateField(u.email, 'Loops - beestPurchasedItem'); + }); + + return result; + }); + } + + /** Get user's pipes balance */ + async getPipes(userId: string): Promise { + const user = await this.userRepo.findOne({ + where: { id: userId }, + select: ['id', 'pipes'], + }); + return user?.pipes ?? 0; + } + + /** Get orders for a specific user */ + async getUserOrders(userId: string) { + const orders = await this.orderRepo.find({ + where: { userId }, + order: { createdAt: 'DESC' }, + select: ['id', 'itemName', 'quantity', 'pipesSpent', 'status', 'createdAt'], + }); + return orders; + } + + /** Get fulfillment updates for a user */ + async getUserFulfillmentUpdates(userId: string) { + const updates = await this.fulfillmentRepo.find({ + where: { userId }, + order: { createdAt: 'DESC' }, + relations: ['order'], + }); + return updates.map((u) => ({ + id: u.id, + orderId: u.orderId, + itemName: u.order?.itemName ?? 'Unknown', + message: u.message, + isRead: u.isRead, + createdAt: u.createdAt, + })); + } + + /** Mark all fulfillment updates as read for a user */ + async markUpdatesRead(userId: string) { + await this.fulfillmentRepo.update({ userId, isRead: false }, { isRead: true }); + } + + /** Count unread fulfillment updates */ + async getUnreadCount(userId: string): Promise { + return this.fulfillmentRepo.count({ where: { userId, isRead: false } }); + } + + // ── Admin methods ── + + /** List all orders with filtering and sorting */ + async listAllOrders(options?: { + shopItemId?: string; + status?: string; + sortBy?: 'oldest' | 'newest'; + }) { + const qb = this.orderRepo + .createQueryBuilder('order') + .leftJoinAndSelect('order.user', 'user') + .select([ + 'order.id', + 'order.userId', + 'order.shopItemId', + 'order.itemName', + 'order.quantity', + 'order.pipesSpent', + 'order.status', + 'order.hcbCardGrantId', + 'order.createdAt', + 'order.updatedAt', + 'user.id', + 'user.name', + 'user.nickname', + 'user.slackId', + 'user.email', + ]); + + if (options?.shopItemId) { + qb.andWhere('order.shopItemId = :shopItemId', { + shopItemId: options.shopItemId, + }); + } + + if (options?.status) { + qb.andWhere('order.status = :status', { status: options.status }); + } + + if (options?.sortBy === 'oldest') { + qb.orderBy('order.createdAt', 'ASC'); + } else { + qb.orderBy('order.createdAt', 'DESC'); + } + + const orders = await qb.getMany(); + + return orders.map((o) => ({ + id: o.id, + userId: o.userId, + shopItemId: o.shopItemId, + itemName: o.itemName, + quantity: o.quantity, + pipesSpent: o.pipesSpent, + status: o.status, + hcbCardGrantId: o.hcbCardGrantId ?? null, + createdAt: o.createdAt, + updatedAt: o.updatedAt, + userName: o.user?.nickname || o.user?.name || 'Unknown', + userSlackId: o.user?.slackId || null, + userEmail: o.user?.email || null, + pendingSince: o.status === 'pending' + ? Math.floor((Date.now() - new Date(o.createdAt).getTime()) / (1000 * 60 * 60)) + : null, + })); + } + + /** Mark an order as fulfilled — uses pessimistic lock to prevent double-fulfill */ + async fulfillOrder(orderId: string) { + return this.dataSource.transaction('SERIALIZABLE', async (manager) => { + const order = await manager.findOne(Order, { + where: { id: orderId }, + lock: { mode: 'pessimistic_write' }, + }); + if (!order) throw new NotFoundException('Order not found'); + if (order.status === 'fulfilled') { + throw new BadRequestException('Order is already fulfilled'); + } + + order.status = 'fulfilled'; + await manager.save(Order, order); + + const update = manager.create(FulfillmentUpdate, { + userId: order.userId, + orderId: order.id, + message: "Hey! I've sent out your order, its on the way to you :)", + isRead: false, + }); + await manager.save(FulfillmentUpdate, update); + + return order; + }).then(async (order) => { + await this.auditLogService.log( + order.userId, + 'order_fulfilled', + `Order for ${order.quantity}x ${order.itemName} was fulfilled`, + ); + + // Sync fulfillment date to Airtable for Loops + this.userRepo.findOne({ where: { id: order.userId }, select: ['email'] }).then((u) => { + if (u?.email) this.rsvpService.updateDateField(u.email, 'Loops - beestFulfilledOrder'); + }); + + return { success: true }; + }); + } + + /** + * Refund an order — returns pipes, restocks the item, and deletes the order + * (which cascades to its fulfillment updates). Used when an order can't be + * fulfilled or was placed in error. + * + * `requireUserId` enforces that the order belongs to that user (used for + * self-refunds); `requirePending` blocks refunds on already-fulfilled orders + * — admins bypass both, users get both. + */ + async refundOrder( + orderId: string, + opts: { adminId?: string; requireUserId?: string; requirePending?: boolean } = {}, + ) { + return this.dataSource.transaction('SERIALIZABLE', async (manager) => { + const order = await manager.findOne(Order, { + where: { id: orderId }, + lock: { mode: 'pessimistic_write' }, + }); + if (!order) throw new NotFoundException('Order not found'); + if (opts.requireUserId && order.userId !== opts.requireUserId) { + throw new ForbiddenException('You do not own this order'); + } + if (opts.requirePending && order.status !== 'pending') { + throw new BadRequestException( + 'Cannot refund an order that has already been fulfilled', + ); + } + + const user = await manager.findOne(User, { + where: { id: order.userId }, + lock: { mode: 'pessimistic_write' }, + }); + if (user) { + user.pipes = (user.pipes ?? 0) + order.pipesSpent; + await manager.save(User, user); + } + + if (order.shopItemId) { + const item = await manager.findOne(ShopItem, { + where: { id: order.shopItemId }, + lock: { mode: 'pessimistic_write' }, + }); + if (item && item.stock !== null) { + item.stock += order.quantity; + await manager.save(ShopItem, item); + } + } + + const snapshot = { + userId: order.userId, + itemName: order.itemName, + quantity: order.quantity, + pipesSpent: order.pipesSpent, + }; + await manager.remove(Order, order); + return snapshot; + }).then(async (snapshot) => { + const isSelf = !!opts.requireUserId; + await this.auditLogService.log( + snapshot.userId, + 'order_refunded', + isSelf + ? `Self-refunded ${snapshot.quantity}x ${snapshot.itemName} (${snapshot.pipesSpent} Pipes returned)` + : `Order for ${snapshot.quantity}x ${snapshot.itemName} was refunded (${snapshot.pipesSpent} Pipes returned)`, + ); + if (opts.adminId) { + await this.auditLogService.log( + opts.adminId, + 'order_refunded', + `Refunded ${snapshot.quantity}x ${snapshot.itemName} (${snapshot.pipesSpent} Pipes returned)`, + ); + } + return { success: true, refundedPipes: snapshot.pipesSpent }; + }); + } + + /** + * Merge other pending orders by the same user for the same shop item into + * the target order. The target keeps its id; quantity and pipesSpent are + * summed, and any FulfillmentUpdate rows attached to the merged-from orders + * are reassigned before those orders are removed. Admin-only — does not + * notify the user. + */ + async mergeOrders(targetOrderId: string, adminId?: string) { + const result = await this.dataSource.transaction('SERIALIZABLE', async (manager) => { + const target = await manager.findOne(Order, { + where: { id: targetOrderId }, + lock: { mode: 'pessimistic_write' }, + }); + if (!target) throw new NotFoundException('Order not found'); + if (target.status !== 'pending') { + throw new BadRequestException('Only pending orders can be merged'); + } + if (!target.shopItemId) { + throw new BadRequestException( + 'Cannot merge orders for an item that no longer exists in the shop', + ); + } + + const candidates = await manager.find(Order, { + where: { + userId: target.userId, + shopItemId: target.shopItemId, + status: 'pending', + }, + lock: { mode: 'pessimistic_write' }, + }); + const others = candidates.filter((o) => o.id !== target.id); + if (others.length === 0) { + throw new BadRequestException('No matching duplicate orders to merge'); + } + + let addedQty = 0; + let addedPipes = 0; + for (const o of others) { + addedQty += o.quantity; + addedPipes += o.pipesSpent; + } + target.quantity += addedQty; + target.pipesSpent += addedPipes; + await manager.save(Order, target); + + // Preserve any fulfillment-update rows on the merged-from orders by + // reassigning them to the target before delete (FK cascades would + // otherwise drop them when the parent order goes away). + const otherIds = others.map((o) => o.id); + await manager.update( + FulfillmentUpdate, + { orderId: In(otherIds) }, + { orderId: target.id }, + ); + + await manager.remove(Order, others); + + return { + target, + mergedCount: others.length, + addedQty, + addedPipes, + }; + }); + + await this.auditLogService.log( + result.target.userId, + 'order_merged', + `Merged ${result.mergedCount} duplicate order(s) into one — now ${result.target.quantity}x ${result.target.itemName}`, + ); + if (adminId) { + await this.auditLogService.log( + adminId, + 'order_merged', + `Merged ${result.mergedCount} duplicate order(s) for ${result.target.itemName} (${result.target.quantity}x total)`, + ); + } + + return { + success: true, + orderId: result.target.id, + mergedCount: result.mergedCount, + quantity: result.target.quantity, + pipesSpent: result.target.pipesSpent, + }; + } + + /** Send a custom fulfillment message */ + async sendFulfillmentMessage(orderId: string, message: string) { + const order = await this.orderRepo.findOne({ where: { id: orderId } }); + if (!order) throw new NotFoundException('Order not found'); + + const clean = message.replace(/[<>"`&\\]/g, '').replace(/\0/g, '').trim().slice(0, 500); + if (!clean) throw new BadRequestException('Message cannot be empty'); + + const update = this.fulfillmentRepo.create({ + userId: order.userId, + orderId: order.id, + message: clean, + isRead: false, + }); + await this.fulfillmentRepo.save(update); + + return { success: true }; + } +} diff --git a/backend/src/slack/slack.module.ts b/backend/src/slack/slack.module.ts new file mode 100644 index 0000000..e6ab4a3 --- /dev/null +++ b/backend/src/slack/slack.module.ts @@ -0,0 +1,8 @@ + import { Module } from '@nestjs/common'; +import { SlackService } from './slack.service'; + +@Module({ + providers: [SlackService], + exports: [SlackService], +}) +export class SlackModule {} diff --git a/backend/src/slack/slack.service.ts b/backend/src/slack/slack.service.ts new file mode 100644 index 0000000..5fc1c78 --- /dev/null +++ b/backend/src/slack/slack.service.ts @@ -0,0 +1,53 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { fetchWithTimeout } from '../fetch.util'; + +export type SlackMembershipStatus = 'full_member' | 'guest' | 'not_found'; + +@Injectable() +export class SlackService { + private readonly logger = new Logger(SlackService.name); + private readonly botToken: string | undefined; + private readonly configured: boolean; + + constructor(private configService: ConfigService) { + this.botToken = this.configService.get('SLACK_BOT_TOKEN'); + this.configured = !!this.botToken; + if (!this.configured) { + this.logger.warn('SLACK_BOT_TOKEN not set — Slack membership checks disabled'); + } + } + + async checkMembership(email: string): Promise { + if (!this.configured) { + throw new Error('Slack integration is not configured'); + } + + const res = await fetchWithTimeout( + `https://slack.com/api/users.lookupByEmail?email=${encodeURIComponent(email)}`, + { headers: { Authorization: `Bearer ${this.botToken}` } }, + ); + + if (!res.ok) { + this.logger.error(`Slack API HTTP error: ${res.status}`); + throw new Error('Slack API request failed'); + } + + const data = await res.json(); + + if (!data.ok) { + if (data.error === 'users_not_found') { + return 'not_found'; + } + this.logger.error(`Slack API error: ${data.error}`); + throw new Error(`Slack API error: ${data.error}`); + } + + const user = data.user; + if (user.is_restricted || user.is_ultra_restricted) { + return 'guest'; + } + + return 'full_member'; + } +} diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts new file mode 100644 index 0000000..36852c5 --- /dev/null +++ b/backend/test/app.e2e-spec.ts @@ -0,0 +1,25 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { AppModule } from './../src/app.module'; + +describe('AppController (e2e)', () => { + let app: INestApplication; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + it('/ (GET)', () => { + return request(app.getHttpServer()) + .get('/') + .expect(200) + .expect('Hello World!'); + }); +}); diff --git a/backend/test/jest-e2e.json b/backend/test/jest-e2e.json new file mode 100644 index 0000000..e9d912f --- /dev/null +++ b/backend/test/jest-e2e.json @@ -0,0 +1,9 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testEnvironment": "node", + "testRegex": ".e2e-spec.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + } +} diff --git a/backend/tsconfig.build.json b/backend/tsconfig.build.json new file mode 100644 index 0000000..64f86c6 --- /dev/null +++ b/backend/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..57f9635 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "module": "nodenext", + "moduleResolution": "nodenext", + "resolvePackageJsonExports": true, + "esModuleInterop": true, + "isolatedModules": true, + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2023", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "forceConsistentCasingInFileNames": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "noFallthroughCasesInSwitch": true + } +} diff --git a/beest/.vscode/extensions.json b/beest/.vscode/extensions.json deleted file mode 100644 index 38f928a..0000000 --- a/beest/.vscode/extensions.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "recommendations": ["svelte.svelte-vscode", "esbenp.prettier-vscode"] -} diff --git a/beest/CLAUDE.md b/beest/CLAUDE.md deleted file mode 100644 index 55be113..0000000 --- a/beest/CLAUDE.md +++ /dev/null @@ -1,77 +0,0 @@ -## Project Configuration - -- **Language**: TypeScript -- **Package Manager**: npm -- **Add-ons**: prettier, mcp -- **Framework**: SvelteKit (Svelte 5 with runes) -- **Entry point**: `src/routes/+page.svelte` — single-page site, all markup and styles in one file - ---- - -You are able to use the Svelte MCP server, where you have access to comprehensive Svelte 5 and SvelteKit documentation. Here's how to use the available tools effectively: - -## Available MCP Tools: - -### 1. list-sections - -Use this FIRST to discover all available documentation sections. Returns a structured list with titles, use_cases, and paths. -When asked about Svelte or SvelteKit topics, ALWAYS use this tool at the start of the chat to find relevant sections. - -### 2. get-documentation - -Retrieves full documentation content for specific sections. Accepts single or multiple sections. -After calling the list-sections tool, you MUST analyze the returned documentation sections (especially the use_cases field) and then use the get-documentation tool to fetch ALL documentation sections that are relevant for the user's task. - -### 3. svelte-autofixer - -Analyzes Svelte code and returns issues and suggestions. -You MUST use this tool whenever writing Svelte code before sending it to the user. Keep calling it until no issues or suggestions are returned. - -### 4. playground-link - -Generates a Svelte Playground link with the provided code. -After completing the code, ask the user if they want a playground link. Only call this tool after user confirmation and NEVER if code was written to files in their project. - -## Design Rules - -- Only use colors from the commented color palette in `src/routes/+page.svelte` for any UI work. -- `filter: saturate(1.5)` is applied to the body — all colors appear more vivid than their hex values suggest. Account for this when picking colors. -- A tileable rock texture (`/images/tile.webp`) is overlaid on all content sections via `::after` pseudo-elements at low opacity with `mix-blend-mode: overlay`. New sections need to be added to the texture selector list in the ` diff --git a/beest/src/routes/FAQ/+layout.svelte b/beest/src/routes/FAQ/+layout.svelte deleted file mode 100644 index 9cebde5..0000000 --- a/beest/src/routes/FAQ/+layout.svelte +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - -{@render children()} diff --git a/beest/src/routes/api/rsvp/+server.ts b/beest/src/routes/api/rsvp/+server.ts deleted file mode 100644 index 185e05c..0000000 --- a/beest/src/routes/api/rsvp/+server.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { AIRTABLE_API_KEY, AIRTABLE_BASE_ID, AIRTABLE_TABLE_NAME } from '$env/static/private'; -import type { RequestHandler } from './$types'; - -const EMAIL_RE = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; - -const pendingEmails = new Set(); - -function sanitizeEmail(raw: string): string { - return raw.trim().slice(0, 254).replace(/[<>"'&\\]/g, ''); -} - -export const POST: RequestHandler = async ({ request }) => { - let body: unknown; - try { - body = await request.json(); - } catch { - return json({ error: 'Invalid JSON' }, { status: 400 }); - } - - const { email: rawEmail } = body as { email?: string }; - - if (!rawEmail || typeof rawEmail !== 'string') { - return json({ error: 'Email is required' }, { status: 400 }); - } - - const email = sanitizeEmail(rawEmail); - - if (!EMAIL_RE.test(email)) { - return json({ error: 'Invalid email address' }, { status: 400 }); - } - - if (pendingEmails.has(email)) { - return json({ error: 'RSVP already in progress' }, { status: 429 }); - } - pendingEmails.add(email); - - try { - // Check if the email already exists in Airtable - const searchParams = new URLSearchParams({ - filterByFormula: `{Email} = '${email.replace(/'/g, "\\'")}'`, - maxRecords: '1' - }); - const checkRes = await fetch( - `https://api.airtable.com/v0/${AIRTABLE_BASE_ID}/${encodeURIComponent(AIRTABLE_TABLE_NAME)}?${searchParams}`, - { - headers: { Authorization: `Bearer ${AIRTABLE_API_KEY}` } - } - ); - - if (!checkRes.ok) { - const text = await checkRes.text(); - console.error('Airtable lookup error:', checkRes.status, text); - return json({ error: 'Failed to check RSVP' }, { status: 502 }); - } - - const existing = await checkRes.json(); - if (existing.records && existing.records.length > 0) { - return json({ success: true, existing: true }); - } - - // Create new record - const res = await fetch(`https://api.airtable.com/v0/${AIRTABLE_BASE_ID}/${encodeURIComponent(AIRTABLE_TABLE_NAME)}`, { - method: 'POST', - headers: { - Authorization: `Bearer ${AIRTABLE_API_KEY}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - records: [ - { - fields: { - Email: email - } - } - ] - }) - }); - - if (!res.ok) { - const text = await res.text(); - console.error('Airtable error:', res.status, text); - return json({ error: 'Failed to save RSVP' }, { status: 502 }); - } - - return json({ success: true, existing: false }); - } finally { - pendingEmails.delete(email); - } -}; diff --git a/beest/static/images/FAQ-Header.png b/beest/static/images/FAQ-Header.png deleted file mode 100644 index fbb77d4..0000000 Binary files a/beest/static/images/FAQ-Header.png and /dev/null differ diff --git a/beest/static/images/FAQ-Header.webp b/beest/static/images/FAQ-Header.webp deleted file mode 100644 index 8400ec9..0000000 Binary files a/beest/static/images/FAQ-Header.webp and /dev/null differ diff --git a/beest/static/images/frames/75 teens at Campfire Flagship.png b/beest/static/images/frames/75 teens at Campfire Flagship.png deleted file mode 100644 index 2672c16..0000000 Binary files a/beest/static/images/frames/75 teens at Campfire Flagship.png and /dev/null differ diff --git a/beest/static/images/frames/Hackathon on an island.png b/beest/static/images/frames/Hackathon on an island.png deleted file mode 100644 index d31bf9e..0000000 Binary files a/beest/static/images/frames/Hackathon on an island.png and /dev/null differ diff --git a/beest/static/images/frames/Teen hackers at Assemble.png b/beest/static/images/frames/Teen hackers at Assemble.png deleted file mode 100644 index 597a3f8..0000000 Binary files a/beest/static/images/frames/Teen hackers at Assemble.png and /dev/null differ diff --git a/beest/static/images/frames/Teens at a local game Jam.png b/beest/static/images/frames/Teens at a local game Jam.png deleted file mode 100644 index 19faf23..0000000 Binary files a/beest/static/images/frames/Teens at a local game Jam.png and /dev/null differ diff --git a/beest/static/images/frames/Winners of Parthenon Hackathon.png b/beest/static/images/frames/Winners of Parthenon Hackathon.png deleted file mode 100644 index f75683a..0000000 Binary files a/beest/static/images/frames/Winners of Parthenon Hackathon.png and /dev/null differ diff --git a/beest/static/images/frames/hackers debugging together.png b/beest/static/images/frames/hackers debugging together.png deleted file mode 100644 index bfa0f7e..0000000 Binary files a/beest/static/images/frames/hackers debugging together.png and /dev/null differ diff --git a/beest/static/images/hero.png b/beest/static/images/hero.png deleted file mode 100644 index 9bbae07..0000000 Binary files a/beest/static/images/hero.png and /dev/null differ diff --git a/beest/static/images/shop/poster.png b/beest/static/images/shop/poster.png deleted file mode 100644 index d10d05c..0000000 Binary files a/beest/static/images/shop/poster.png and /dev/null differ diff --git a/beest/static/images/sticker.png b/beest/static/images/sticker.png deleted file mode 100644 index 39ebfce..0000000 Binary files a/beest/static/images/sticker.png and /dev/null differ diff --git a/beest/static/images/sticker.webp b/beest/static/images/sticker.webp deleted file mode 100644 index 6221ed1..0000000 Binary files a/beest/static/images/sticker.webp and /dev/null differ diff --git a/beest/static/images/tile.png b/beest/static/images/tile.png deleted file mode 100644 index 630e961..0000000 Binary files a/beest/static/images/tile.png and /dev/null differ diff --git a/beest/static/images/tile.webp b/beest/static/images/tile.webp deleted file mode 100644 index e8da774..0000000 Binary files a/beest/static/images/tile.webp and /dev/null differ diff --git a/beest/svelte.config.js b/beest/svelte.config.js deleted file mode 100644 index c0e3ab1..0000000 --- a/beest/svelte.config.js +++ /dev/null @@ -1,17 +0,0 @@ -import adapter from '@sveltejs/adapter-node'; - -/** @type {import('@sveltejs/kit').Config} */ -const config = { - kit: { - // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. - // If your environment is not supported, or you settled on a specific environment, switch out the adapter. - // See https://svelte.dev/docs/kit/adapters for more information about adapters. - adapter: adapter() - }, - vitePlugin: { - dynamicCompileOptions: ({ filename }) => - filename.includes('node_modules') ? undefined : { runes: true } - } -}; - -export default config; diff --git a/docs/good-review.md b/docs/good-review.md new file mode 100644 index 0000000..65505d4 --- /dev/null +++ b/docs/good-review.md @@ -0,0 +1,124 @@ +# What a Good Review Looks Like + +Hey gang! Thank you for taking the time to read this and for offering to help with project review. Reviewing properly is amazingly helpful and will take a lot of work off my plate, but reviewing badly can actually lead to my budget getting fined or people getting the wrong payouts, feedback or results. + +In my experience, long review times or unclear review feedback is a big pain point for participants, so this doc should explain how to do review perfectly :) + +## The Overall Goal + +Your role as a reviewer is to make the final call as to if someones project is worthy of a reward, needs changes to become worthy, or is an attempt to misrepresent effort or defraud the system. For the most part, you are looking at many hours of peoples invested time, so where possible always assume good faith. Equally, incompetence is far more common than ill intent. + +Ultimately balancing the time it takes to review all projects with the quality of feedback provided and the amount of fraud caught is a matter of taste / opinion. So here is my opinion! + +- Participant experience comes first! The number one goal is for people to feel that the reviewers are there to help them make the project good and that they are not holding them back from shipping faster. We are not here to enforce rigorous arbitrary standards, rather use good judgement. If somebody is new to GH, sloppy commit history is justifiable; if they are experienced, hold them to higher standards! + +- The second most important element is that the reviews are of high quality. We have a long checklist which I will go over in more detail, but as set out above, a bad review costs us time and money, so be thorough! + +- The third highest priority is time. If it takes time to review well, that's okay. I'd rather have a slow good process than a fast bad one. + +## So what is a good review? + +When making a review you will have 3 options :) + +- **Approve**: the project works exactly as described, it shows real learning, challenge or creativity, and the hours tracked are reasonable for the features of the project. + +- **Reject (`changes_needed`)**: the project doesn't meet our standards! **Feedback is required**, and the Reject button is disabled until the user feedback box has text, because this is the message the builder sees. If we are rejecting a project it should be very clear why it was rejected and should have actionable feedback for the user to resubmit and be accepted. + +- **Fail & Ban**: the user has blatantly attempted to defraud the system beyond the doubt of good faith. This should be backed up by evidence and a history of interactions wherein the user has not shown intent to do better after making what could be passed off as an honest mistake. Run this one by me before using it! + +Every review also writes a `ProjectReview` row with your feedback, internal note, override justification, and hour adjustments. Past reviews on the same project are visible underneath the review panel, so read them before you start. + +## Before you form an opinion, read the Hackatime panel + +The Hackatime context panel (visible while the project is `unreviewed` or `approved`) exists to save you from being fooled. Work through it top to bottom: + +- **Trust level badge**: `blue` is standard, `red` is banned. Something has broken if you see red — pass it on to me! + +- **Email mismatch warning**: "The linked Hackatime user does not contain this builder's email" strongly suggests a shared or alt account. Treat as a blocker and pass on to euan unless explained. + +- **Hackatime banned**: don't approve, full stop. Pass it on to me. + +- **Unified Airtable duplicate**: the code URL already exists in Unified Approved Projects. Someone else already got paid for this, or the builder is resubmitting across programs. Investigate before approving; the user should have reported that the project is submitted to another program. This is only acceptable when significant updates have been made and only shipped to beest. + +- **Previous approved hours + delta**: on resubmissions, this tells you what was *already* paid out. The delta is what you're about to grant on top, so make sure it is indicative of substantial work (check commit history!) + +- **Hackatime projects breakdown**: the listed project names and languages should line up with the actual repo. A React app backed by 40 hours in a project called `untitled-1` is a flag. + +If any of these are off, resolve the question before you touch the hours. I need people who are thorough and follow guidelines / read carefully. +If you see this, send me the word 'truncheon' and I will add you as a reviewer. + +## Judging the project itself + +The header buttons (GitHub, Demo, README, Docs) are how you actually assess the work. You should be pressing ALL of these before reviewing! A good project needs a README that details what the project does, a linked GitHub repo with substantial commit history (~1 commit per hour, each with decent code updates), and a demo link that lets you experience the project without any code setup. + +The Docs button opens the documentation detailing the technical requirements to submit a project, and most of what it says should be written here as well. + +- **GitHub**: does the commit history look like someone built this over the claimed time, or like one dump of generated code? + +- **Demo**: does it run? Does it do what the description says? Does it actually serve a purpose or seem like a real project someone would want to exist? + +- **README**: does it match the demo and the repo? Is it AI-slop boilerplate or does the builder understand what they made? + +- **Screenshots** (the in-panel carousel, `screenshot1Url` / `screenshot2Url`): sanity check against the demo. + +- **AI Usage field**: the builder's self-report. Not a reason to reject on its own, but weigh it against what you see in the repo. As a rule of thumb 30% AI usage declared is the maximum okay amount. If it looks like AI but isn't declared, reject it! + +- **Resubmission Notes (`changeDescription`)**: on resubmissions, this is the builder's answer to the last reviewer. If it doesn't address the previous feedback, that's your first reject reason. + +- **Min Hours confirmation**: self-attestation that they hit the minimum. Not evidence, just a checkbox. Please double check if a project is being resubmitted that they have worked on it for at least 3 more hours and made progress. + +## Setting hours + +There are two hour fields, and which one you use matters! + +- **User Facing Hours (`overrideHours`)**: what the builder sees, and what pipes are calculated from. `floor(overrideHours) - previouslyGranted` is the delta that gets granted. This should be the full amount most of the time, but can be reduced if the project is of low quality (AI usage too high, reported time doesn't make sense, etc.). I will often halve for AI. + +- **Internal Hours (`internalHours`)**: the time we can be absolutely sure was spent working on the project. If you have doubts, we aggressively deflate this field. It is not user facing and is instead used for internal book keeping. If something feels off but you can't pinpoint why, deflate hours here and write an internal note. + +## Justifying the review + +All projects should show a preview review. That on its own is never enough; it just includes relevant information. You need to also include a personal review. This should include: + +1. The name of the Hackatime project and the date range that was analyzed (e.g. "from Feb 3 to Feb 17"), along with a summary of what the data shows (e.g. "Hackatime project trivia-game analyzed from Feb 3 to Feb 17 shows 14.2 hours tracked. The heartbeat pattern is consistent with active development."). + +2. A written explanation that references concrete details about the project: what was built, what makes the scope consistent with the approved hours, and what evidence the reviewer examined. For example: "The project includes a custom physics engine, multiplayer networking, and 8 hand-drawn levels. The commit history shows 47 commits over 3 weeks. 35 hours is consistent with this scope." + +3. For programs that use Lapse or any other timelapse tool, the justification must also include a URL to the relevant Lapse video along with a description of what the video demonstrates. For example: "Lapse video at [URL] shows the submitter assembling their hardware project over 6 sessions. The video covers component soldering, wiring, and firmware flashing. The pace and progress are consistent with the claimed 12 hours." + +## Writing the two feedback fields + +These are two different audiences! + +**User Feedback** (placeholder: *"Feedback to send to the user about their project..."*) goes to the builder. Write it for a teenager who just spent real time on this and is about to read your words. + +- Lead with what's working. Even on a reject. +- Be specific about what needs to change. "Add a README to explain what the project is and does" beats "I can't accept this because I don't know what it is". +- If you reduce their hours, explain the reduction here too! + +**Internal Note** (placeholder: *"Private note for reviewers only, not visible to the user..."*) is where to put hunches, suspicions, and useful notes to yourself or another reviewer. The note is scoped to the person not the project, so mention the relevant project. + +You can also add tidbits and tags! It's always useful to have context and I'm happy for you to note down "beginner", "hardware oriented", "vibe coder", "skilled" or anything else :) + +If you're tempted to put something passive-aggressive in user feedback, it probably belongs here instead. + +## When to reject + +- The project is AI slop (use judgement) +- The project looks like homework +- The project is submitted to another YSWS without new work +- The project was started before april 3rd and additional work not shown +- The readme doesn't detail what the project is or does +- The demo needs python installed to run +- The demo does not run +- The commit history is not distributed over a reasonable period of time +- The demo is not usable without very easy, documented set up + +## When to not review + +Sometimes you won't have the right OS to try the project, or you won't have the hardware experience to judge the time taken. In these cases just skip that project and leave it for someone else! + +## What a bad review looks like + +- Approving without opening the repo or demo. +- Rejecting with feedback like "needs more work". The builder has no idea what to do next. +- Not writing anything in the justification. This costs me money! Don't do it! diff --git a/beest/.gitignore b/frontend/.gitignore similarity index 100% rename from beest/.gitignore rename to frontend/.gitignore diff --git a/beest/.mcp.json b/frontend/.mcp.json similarity index 100% rename from beest/.mcp.json rename to frontend/.mcp.json diff --git a/beest/.node-version b/frontend/.node-version similarity index 100% rename from beest/.node-version rename to frontend/.node-version diff --git a/beest/.npmrc b/frontend/.npmrc similarity index 100% rename from beest/.npmrc rename to frontend/.npmrc diff --git a/beest/.prettierignore b/frontend/.prettierignore similarity index 100% rename from beest/.prettierignore rename to frontend/.prettierignore diff --git a/beest/.prettierrc b/frontend/.prettierrc similarity index 100% rename from beest/.prettierrc rename to frontend/.prettierrc diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..e82ad1c --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,15 @@ +FROM node:22-alpine AS build +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM node:22-alpine +WORKDIR /app +COPY --from=build /app/build ./build +COPY --from=build /app/package*.json ./ +RUN npm ci --omit=dev +EXPOSE 3000 +ENV BODY_SIZE_LIMIT=10485760 +CMD ["node", "build"] diff --git a/beest/README.md b/frontend/README.md similarity index 100% rename from beest/README.md rename to frontend/README.md diff --git a/beest/package-lock.json b/frontend/package-lock.json similarity index 53% rename from beest/package-lock.json rename to frontend/package-lock.json index 099fc89..ed9a458 100644 --- a/beest/package-lock.json +++ b/frontend/package-lock.json @@ -7,17 +7,63 @@ "": { "name": "beest", "version": "0.0.1", + "dependencies": { + "@fontsource/opendyslexic": "^5.2.5", + "canvas-confetti": "^1.9.4", + "d3-array": "^3.2.4", + "d3-scale": "^4.0.2", + "d3-time": "^3.1.0", + "d3-time-format": "^4.1.0", + "layerchart": "^1.0.13", + "uplot": "^1.6.32" + }, "devDependencies": { "@sveltejs/adapter-auto": "^7.0.0", "@sveltejs/adapter-node": "^5.5.4", "@sveltejs/kit": "^2.50.2", "@sveltejs/vite-plugin-svelte": "^6.2.4", + "@types/canvas-confetti": "^1.9.0", + "@types/d3-scale": "^4.0.9", + "@types/d3-time-format": "^4.0.3", "prettier": "^3.8.1", "prettier-plugin-svelte": "^3.4.1", "svelte": "^5.51.0", "svelte-check": "^4.4.2", "typescript": "^5.9.3", - "vite": "^7.3.1" + "vite": "^7.3.2" + }, + "engines": { + "node": ">=22.12" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@dagrejs/dagre": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-1.1.8.tgz", + "integrity": "sha512-5SEDlndt4W/LaVzPYJW+bSmSEZc9EzTf8rJ20WCKvjS5EAZAN0b+x0Yww7VMT4R3Wootkg+X9bUfUxazYw6Blw==", + "license": "MIT", + "dependencies": { + "@dagrejs/graphlib": "2.2.4" + } + }, + "node_modules/@dagrejs/graphlib": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-2.2.4.tgz", + "integrity": "sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw==", + "license": "MIT", + "engines": { + "node": ">17.0.0" } }, "node_modules/@esbuild/aix-ppc64": { @@ -462,11 +508,44 @@ "node": ">=18" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@fontsource/opendyslexic": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@fontsource/opendyslexic/-/opendyslexic-5.2.5.tgz", + "integrity": "sha512-NNS9aaPQx2TlaTvb3vTEjw3xz8lKj23mBc+6rM00mSNFDygdoll0/nLMHFtDKKrBT6sMfY6TFFPOR0D9ktdspg==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -477,7 +556,6 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -488,7 +566,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -498,20 +575,108 @@ "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/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@layerstack/svelte-actions": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@layerstack/svelte-actions/-/svelte-actions-1.0.1.tgz", + "integrity": "sha512-Tv8B3TeT7oaghx0R0I4avnSdfAT6GxEK+StL8k/hEaa009iNOIGFl3f76kfvNvPioQHAMFGtnWGLPHfsfD41nQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.6.13", + "@layerstack/utils": "1.0.1", + "d3-array": "^3.2.4", + "d3-scale": "^4.0.2", + "date-fns": "^4.1.0", + "lodash-es": "^4.17.21" + } + }, + "node_modules/@layerstack/svelte-stores": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@layerstack/svelte-stores/-/svelte-stores-1.0.2.tgz", + "integrity": "sha512-IxK0UKD0PVxg1VsyaR+n7NyJ+NlvyqvYYAp+J10lkjDQxm0yx58CaF2LBV08T22C3aY1iTlqJaatn/VHV4SoQg==", + "license": "MIT", + "dependencies": { + "@layerstack/utils": "1.0.1", + "d3-array": "^3.2.4", + "date-fns": "^4.1.0", + "immer": "^10.1.1", + "lodash-es": "^4.17.21", + "zod": "^3.24.2" + } + }, + "node_modules/@layerstack/tailwind": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@layerstack/tailwind/-/tailwind-1.0.1.tgz", + "integrity": "sha512-nlshEkUCfaV0zYzrFXVVYRnS8bnBjs4M7iui6l/tu6NeBBlxDivIyRraJkdYGCSL1lZHi6FqacLQ3eerHtz90A==", + "license": "MIT", + "dependencies": { + "@layerstack/utils": "^1.0.1", + "clsx": "^2.1.1", + "culori": "^4.0.1", + "d3-array": "^3.2.4", + "date-fns": "^4.1.0", + "lodash-es": "^4.17.21", + "tailwind-merge": "^2.5.4", + "tailwindcss": "^3.4.15" + } + }, + "node_modules/@layerstack/utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@layerstack/utils/-/utils-1.0.1.tgz", + "integrity": "sha512-sWP9b+SFMkJYMZyYFI01aLxbg2ZUrix6Tv+BCDmeOrcLNxtWFsMYAomMhALzTMHbb+Vis/ua5vXhpdNXEw8a2Q==", + "license": "MIT", + "dependencies": { + "d3-array": "^3.2.4", + "date-fns": "^4.1.0", + "lodash-es": "^4.17.21" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -983,10 +1148,9 @@ "license": "MIT" }, "node_modules/@sveltejs/acorn-typescript": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", - "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", - "dev": true, + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz", + "integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==", "license": "MIT", "peerDependencies": { "acorn": "^8.9.0" @@ -1019,18 +1183,18 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.55.0", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.55.0.tgz", - "integrity": "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==", + "version": "2.62.0", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.62.0.tgz", + "integrity": "sha512-4JlkXGRJ3kW15dL4LCHV3Mu5aSTTtmH8EBNE4QjJl+KLY77dClgAsZg8aebpwFcDXemNP1z9az8EatD2UNWAcQ==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", - "@sveltejs/acorn-typescript": "^1.0.5", + "@sveltejs/acorn-typescript": "^1.0.9", "@types/cookie": "^0.6.0", - "acorn": "^8.14.1", + "acorn": "^8.16.0", "cookie": "^0.6.0", - "devalue": "^5.6.4", + "devalue": "^5.8.1", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", @@ -1048,7 +1212,7 @@ "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", - "typescript": "^5.3.3", + "typescript": "^5.3.3 || ^6.0.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "peerDependenciesMeta": { @@ -1099,6 +1263,13 @@ "vite": "^6.3.0 || ^7.0.0" } }, + "node_modules/@types/canvas-confetti": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@types/canvas-confetti/-/canvas-confetti-1.9.0.tgz", + "integrity": "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", @@ -1106,11 +1277,34 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, "node_modules/@types/resolve": { @@ -1124,28 +1318,12 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "dev": true, "license": "MIT" }, - "node_modules/@typescript-eslint/types": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", - "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==", - "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/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" @@ -1154,11 +1332,47 @@ "node": ">=0.4.0" } }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "license": "MIT" + }, "node_modules/aria-query": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -1168,12 +1382,54 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" } }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/canvas-confetti": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.4.tgz", + "integrity": "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==", + "license": "ISC", + "funding": { + "type": "donate", + "url": "https://www.paypal.me/kirilvatev" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -1194,12 +1450,20 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/commondir": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", @@ -1217,138 +1481,555 @@ "node": ">= 0.6" } }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, "engines": { - "node": ">=0.10.0" + "node": ">=4" } }, - "node_modules/devalue": { - "version": "5.6.4", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", - "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", - "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", - "dev": true, - "hasInstallScript": true, + "node_modules/culori": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/culori/-/culori-4.0.2.tgz", + "integrity": "sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw==", "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.4", - "@esbuild/android-arm": "0.27.4", - "@esbuild/android-arm64": "0.27.4", - "@esbuild/android-x64": "0.27.4", - "@esbuild/darwin-arm64": "0.27.4", - "@esbuild/darwin-x64": "0.27.4", - "@esbuild/freebsd-arm64": "0.27.4", - "@esbuild/freebsd-x64": "0.27.4", - "@esbuild/linux-arm": "0.27.4", - "@esbuild/linux-arm64": "0.27.4", - "@esbuild/linux-ia32": "0.27.4", - "@esbuild/linux-loong64": "0.27.4", - "@esbuild/linux-mips64el": "0.27.4", - "@esbuild/linux-ppc64": "0.27.4", - "@esbuild/linux-riscv64": "0.27.4", - "@esbuild/linux-s390x": "0.27.4", - "@esbuild/linux-x64": "0.27.4", - "@esbuild/netbsd-arm64": "0.27.4", - "@esbuild/netbsd-x64": "0.27.4", - "@esbuild/openbsd-arm64": "0.27.4", - "@esbuild/openbsd-x64": "0.27.4", - "@esbuild/openharmony-arm64": "0.27.4", - "@esbuild/sunos-x64": "0.27.4", - "@esbuild/win32-arm64": "0.27.4", - "@esbuild/win32-ia32": "0.27.4", - "@esbuild/win32-x64": "0.27.4" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, - "node_modules/esm-env": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", - "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/esrap": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.4.tgz", - "integrity": "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==", - "dev": true, - "license": "MIT", + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15", - "@typescript-eslint/types": "^8.2.0" + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" } }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true, - "license": "MIT" - }, - "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", + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } + "engines": { + "node": ">=12" } }, - "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" - ], + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo-voronoi": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/d3-geo-voronoi/-/d3-geo-voronoi-2.1.0.tgz", + "integrity": "sha512-kqE4yYuOjPbKdBXG0xztCacPwkVSK2REF1opSNrnqqtXJmNcM++UbwQ8SxvwP6IQTj9RvIjjK4qeiVsEfj0Z2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-delaunay": "6", + "d3-geo": "3", + "d3-tricontour": "1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate-path": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/d3-interpolate-path/-/d3-interpolate-path-2.3.0.tgz", + "integrity": "sha512-tZYtGXxBmbgHsIc9Wms6LS5u4w6KbP8C09a4/ZYc4KLMYYqub57rRBUgpUr2CIarIrJEpdAWWxWQvofgaMpbKQ==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-tile": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/d3-tile/-/d3-tile-1.0.0.tgz", + "integrity": "sha512-79fnTKpPMPDS5xQ0xuS9ir0165NEwwkFpe/DSOmc2Gl9ldYzKKRDWogmTTE8wAJ8NA7PMapNfEcyKhI9Lxdu5Q==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-tricontour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/d3-tricontour/-/d3-tricontour-1.1.0.tgz", + "integrity": "sha512-G7gHKj89n2owmkGb6WX6ixcnQ0Kf/0wpa9VIh9DGdbHu8wdrlaHU4ir3/bFNERl8N8nn4G7e7qbtBG8N9caihQ==", + "license": "ISC", + "dependencies": { + "d3-delaunay": "6", + "d3-scale": "4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/devalue": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "license": "MIT" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.11", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.11.tgz", + "integrity": "sha512-gPdx+I+BjYEinNMQaBXFjbaJVyoPMU4ZODg5mE+M4DqVG9VusAVHHjcBX+zqyITlI0DIARwDMMzZwAWj36dRoQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "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/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, + "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==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -1357,11 +2038,53 @@ "node": ">= 0.4" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, "license": "MIT", "dependencies": { "hasown": "^2.0.2" @@ -1373,6 +2096,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-module": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", @@ -1380,16 +2124,33 @@ "dev": true, "license": "MIT" }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/is-reference": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.6" } }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -1400,23 +2161,133 @@ "node": ">=6" } }, + "node_modules/layercake": { + "version": "8.4.3", + "resolved": "https://registry.npmjs.org/layercake/-/layercake-8.4.3.tgz", + "integrity": "sha512-PZDduaPFxgHHkxlmsz5MVBECf6ZCT39DI3LgMVvuMwrmlrtlXwXUM/elJp46zHYzCE1j+cGyDuBDxnANv94tOQ==", + "license": "MIT", + "dependencies": { + "d3-array": "^3.2.4", + "d3-color": "^3.1.0", + "d3-scale": "^4.0.2", + "d3-shape": "^3.2.0" + }, + "peerDependencies": { + "svelte": "3 - 5 || >=5.0.0-next.120", + "typescript": "^5.0.2" + } + }, + "node_modules/layerchart": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/layerchart/-/layerchart-1.0.13.tgz", + "integrity": "sha512-bjcrfyTdHtfYZn7yj26dvA1qUjM+R6+akp2VeBJ4JWKmDGhb5WvT9nMCs52Rb+gSd/omFq5SjZLz49MqlVljZw==", + "license": "MIT", + "dependencies": { + "@dagrejs/dagre": "^1.1.4", + "@layerstack/svelte-actions": "^1.0.1", + "@layerstack/svelte-stores": "^1.0.2", + "@layerstack/tailwind": "^1.0.1", + "@layerstack/utils": "^1.0.1", + "d3-array": "^3.2.4", + "d3-color": "^3.1.0", + "d3-delaunay": "^6.0.4", + "d3-dsv": "^3.0.1", + "d3-force": "^3.0.0", + "d3-geo": "^3.1.1", + "d3-geo-voronoi": "^2.1.0", + "d3-hierarchy": "^3.1.2", + "d3-interpolate": "^3.0.1", + "d3-interpolate-path": "^2.3.0", + "d3-path": "^3.1.0", + "d3-quadtree": "^3.0.1", + "d3-random": "^3.0.1", + "d3-sankey": "^0.12.3", + "d3-scale": "^4.0.2", + "d3-scale-chromatic": "^3.1.0", + "d3-shape": "^3.2.0", + "d3-tile": "^1.0.0", + "d3-time": "^3.1.0", + "date-fns": "^4.1.0", + "layercake": "8.4.3", + "lodash-es": "^4.17.21" + }, + "peerDependencies": { + "svelte": "^3.56.0 || ^4.0.0 || ^5.0.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, "node_modules/locate-character": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "dev": true, + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "license": "MIT" }, "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/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -1437,11 +2308,21 @@ "node": ">=10" } }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "funding": [ { "type": "github", @@ -1456,6 +2337,33 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -1471,21 +2379,18 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, "license": "MIT" }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -1494,11 +2399,28 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", - "dev": true, + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "funding": [ { "type": "opencollective", @@ -1515,7 +2437,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -1523,6 +2445,134 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, "node_modules/prettier": { "version": "3.8.1", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", @@ -1550,6 +2600,35 @@ "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" } }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "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/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -1568,7 +2647,6 @@ "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", - "dev": true, "license": "MIT", "dependencies": { "is-core-module": "^2.16.1", @@ -1585,6 +2663,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, "node_modules/rollup": { "version": "4.60.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", @@ -1630,6 +2724,35 @@ "fsevents": "~2.3.2" } }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "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", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, "node_modules/sade": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", @@ -1643,6 +2766,12 @@ "node": ">=6" } }, + "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/set-cookie-parser": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", @@ -1669,17 +2798,46 @@ "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/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -1689,24 +2847,23 @@ } }, "node_modules/svelte": { - "version": "5.55.0", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.0.tgz", - "integrity": "sha512-SThllKq6TRMBwPtat7ASnm/9CDXnIhBR0NPGw0ujn2DVYx9rVwsPZxDaDQcYGdUz/3BYVsCzdq7pZarRQoGvtw==", - "dev": true, + "version": "5.56.1", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.1.tgz", + "integrity": "sha512-eArsJmvl3xZVuTYD852PzIEdg2wgDdIZ1NEsIPbzAukHwi284B18No4nK2rCO9AwsWUDza4Cjvmoa4HaojTl5g==", "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", - "@sveltejs/acorn-typescript": "^1.0.5", + "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", - "devalue": "^5.6.4", + "devalue": "^5.8.1", "esm-env": "^1.2.1", - "esrap": "^2.2.2", + "esrap": "^2.2.9", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", @@ -1740,11 +2897,138 @@ "typescript": ">=5.0.0" } }, + "node_modules/tailwind-merge": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", + "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/tailwindcss/node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/tailwindcss/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tailwindcss/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -1757,6 +3041,18 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -1767,11 +3063,16 @@ "node": ">=6" } }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "license": "Apache-2.0" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -1781,10 +3082,22 @@ "node": ">=14.17" } }, + "node_modules/uplot": { + "version": "1.6.32", + "resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.32.tgz", + "integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==", + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", "dependencies": { @@ -1880,8 +3193,16 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", - "dev": true, "license": "MIT" + }, + "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/beest/package.json b/frontend/package.json similarity index 68% rename from beest/package.json rename to frontend/package.json index eabbe89..69fa2db 100644 --- a/beest/package.json +++ b/frontend/package.json @@ -22,11 +22,24 @@ "@sveltejs/adapter-node": "^5.5.4", "@sveltejs/kit": "^2.50.2", "@sveltejs/vite-plugin-svelte": "^6.2.4", + "@types/canvas-confetti": "^1.9.0", + "@types/d3-scale": "^4.0.9", + "@types/d3-time-format": "^4.0.3", "prettier": "^3.8.1", "prettier-plugin-svelte": "^3.4.1", "svelte": "^5.51.0", "svelte-check": "^4.4.2", "typescript": "^5.9.3", - "vite": "^7.3.1" + "vite": "^7.3.2" + }, + "dependencies": { + "@fontsource/opendyslexic": "^5.2.5", + "canvas-confetti": "^1.9.4", + "d3-array": "^3.2.4", + "d3-scale": "^4.0.2", + "d3-time": "^3.1.0", + "d3-time-format": "^4.1.0", + "layerchart": "^1.0.13", + "uplot": "^1.6.32" } } diff --git a/beest/src/app.d.ts b/frontend/src/app.d.ts similarity index 100% rename from beest/src/app.d.ts rename to frontend/src/app.d.ts diff --git a/beest/src/app.html b/frontend/src/app.html similarity index 55% rename from beest/src/app.html rename to frontend/src/app.html index 80e7670..5fa32bf 100644 --- a/beest/src/app.html +++ b/frontend/src/app.html @@ -3,8 +3,11 @@ + + + %sveltekit.head% diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts new file mode 100644 index 0000000..ecc45c6 --- /dev/null +++ b/frontend/src/hooks.server.ts @@ -0,0 +1,10 @@ +import { redirect, type Handle } from '@sveltejs/kit'; + +export const handle: Handle = async ({ event, resolve }) => { + const host = event.request.headers.get('host'); + if (host === 'beast.hackclub.com') { + const target = `https://beest.hackclub.com${event.url.pathname}${event.url.search}`; + throw redirect(308, target); + } + return resolve(event); +}; diff --git a/beest/src/lib/assets/favicon.svg b/frontend/src/lib/assets/favicon.svg similarity index 100% rename from beest/src/lib/assets/favicon.svg rename to frontend/src/lib/assets/favicon.svg diff --git a/frontend/src/lib/components/admin/CardGrantModal.svelte b/frontend/src/lib/components/admin/CardGrantModal.svelte new file mode 100644 index 0000000..0fd5f47 --- /dev/null +++ b/frontend/src/lib/components/admin/CardGrantModal.svelte @@ -0,0 +1,323 @@ + + +
{ + if (e.key === 'Escape') onClose(); + }} +> + + +
+ + diff --git a/frontend/src/lib/components/admin/TimelapsePanel.svelte b/frontend/src/lib/components/admin/TimelapsePanel.svelte new file mode 100644 index 0000000..b83c2f8 --- /dev/null +++ b/frontend/src/lib/components/admin/TimelapsePanel.svelte @@ -0,0 +1,141 @@ + + +{#if loaded && timelapses.length > 0} +
+

Timelapses ({timelapses.length})

+
    + {#each timelapses as t (t.id)} +
  • +
    + {t.name || 'Untitled'} + {#if t.hackatimeProject} + {t.hackatimeProject} + {/if} + {#if t.duration} + · {fmtDuration(t.duration)} + {/if} + {#if t.createdAt} + · {fmtDate(t.createdAt)} + {/if} +
    + +
  • + {/each} +
+
+{/if} + + diff --git a/frontend/src/lib/components/audit/ActivityTimelineChart.svelte b/frontend/src/lib/components/audit/ActivityTimelineChart.svelte new file mode 100644 index 0000000..dbbe428 --- /dev/null +++ b/frontend/src/lib/components/audit/ActivityTimelineChart.svelte @@ -0,0 +1,546 @@ + + +
+ {#if loading} +
connecting to hackatime…
+ {:else if loadError} +
failed to load: {loadError}
+ {:else if !data && progressDays} +
+
+ streaming hackatime · day {progressDays.seen} / {progressDays.total} + {progressDays.matched.toLocaleString()} matched +
+
+
0 + ? `${Math.round((progressDays.seen / progressDays.total) * 100)}%` + : '0%'} + >
+
+
+ {:else if data?.error} +
+ + {#if data.error === 'owner-not-linked'}Owner hasn't linked Hackatime + {:else if data.error === 'no-hackatime-project'}No Hackatime project linked + {:else if data.error === 'hackatime-auth-failed'}Hackatime auth failed + {:else}Hackatime fetch failed{/if} + + no heartbeat graph available +
+ {:else if data} +
+ {data.summary.count.toLocaleString()} heartbeats · + {#if filteredActiveMinutes !== null} + + {fmtHoursMinutes(filteredActiveMinutes)} active + of {fmtHoursMinutes(data.summary.activeMinutes)} + + {:else} + {fmtHoursMinutes(data.summary.activeMinutes)} active + {/if} + · {data.summary.coveredDays} days + {#if typeof data.summary.aiPercent === 'number'} + = 25 && data.summary.aiPercent < 60} + class:ai-high={data.summary.aiPercent >= 60} + title="heartbeats matched as AI editor / AI category / large paste" + > + {data.summary.aiPercent}% AI/paste + + {/if} +
+ +
+ {#if hoverInfo} +
+ time {fmtTs(hoverInfo.t)} + line {hoverInfo.ln ?? '—'} + cursor {hoverInfo.cp ?? '—'} +
+ {/if} +
+ + + +
+
+
+ +

Click an activity pill to exclude its time (e.g. AI Coding) — the approved hours below update automatically.

+ +
+ Categories: + {#if categoryEntries.length === 0} + none + {:else} + {#each categoryEntries as [name, count] (name)} + + {/each} + {/if} +
+ +
+ Editors: + {#if editorEntries.length === 0} + none + {:else} + {#each editorEntries as [name, count] (name)} + + {/each} + {/if} +
+ {/if} +
+ + diff --git a/beest/src/lib/index.ts b/frontend/src/lib/index.ts similarity index 100% rename from beest/src/lib/index.ts rename to frontend/src/lib/index.ts diff --git a/frontend/src/lib/server/auth.ts b/frontend/src/lib/server/auth.ts new file mode 100644 index 0000000..b4c5336 --- /dev/null +++ b/frontend/src/lib/server/auth.ts @@ -0,0 +1,149 @@ +import { env } from '$env/dynamic/private'; +import type { Cookies } from '@sveltejs/kit'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +const COOKIE_OPTS = { + path: '/', + httpOnly: true, + sameSite: 'lax' as const, + secure: env.NODE_ENV === 'production' +}; + +/** + * Attempts to refresh the auth token using the refresh token cookie. + * Returns the new token on success, null if refresh fails or no refresh token exists. + * Sets new cookies when a refresh occurs. + */ +export async function tryRefreshToken( + cookies: Cookies +): Promise { + const refreshTok = cookies.get('refresh_token'); + if (!refreshTok) { + cookies.delete('auth_token', { path: '/' }); + return null; + } + + const res = await fetch(`${BACKEND_URL}/api/auth/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken: refreshTok }) + }); + + if (!res.ok) { + cookies.delete('auth_token', { path: '/' }); + cookies.delete('refresh_token', { path: '/' }); + return null; + } + + const data = await res.json(); + cookies.set('auth_token', data.token, { ...COOKIE_OPTS, maxAge: 3600 }); + cookies.set('refresh_token', data.refreshToken, { + ...COOKIE_OPTS, + maxAge: 90 * 24 * 60 * 60 + }); + return data.token; +} + +/** + * Proxies a request to the backend with auth. If the backend returns 401, + * attempts a token refresh and retries once. + */ +export async function proxyWithRefresh( + cookies: Cookies, + backendUrl: string, + init?: RequestInit +): Promise { + let token = cookies.get('auth_token'); + if (!token) { + token = await tryRefreshToken(cookies) ?? undefined; + if (!token) { + return new Response(JSON.stringify({ error: 'Not authenticated' }), { + status: 401, + headers: { 'Content-Type': 'application/json' } + }); + } + } + + let res = await fetch(backendUrl, { + ...init, + headers: { ...init?.headers, Authorization: `Bearer ${token}` } + }); + + if (res.status === 401) { + const newToken = await tryRefreshToken(cookies); + if (!newToken) { + return new Response(JSON.stringify({ error: 'Not authenticated' }), { + status: 401, + headers: { 'Content-Type': 'application/json' } + }); + } + res = await fetch(backendUrl, { + ...init, + headers: { ...init?.headers, Authorization: `Bearer ${newToken}` } + }); + } + + const data = await res.json().catch(() => ({})); + + // If the backend issued a new JWT (e.g. after nickname update), persist it + if (res.ok && data.token) { + cookies.set('auth_token', data.token, { ...COOKIE_OPTS, maxAge: 3600 }); + } + + return new Response(JSON.stringify(data), { + status: res.status, + headers: { 'Content-Type': 'application/json' } + }); +} + +/** + * Tries to authenticate the user via JWT, falling back to refresh token. + * Returns user claims on success, null on failure (both tokens expired). + * Transparently sets new cookies when a refresh occurs. + */ +export async function getAuthenticatedUser( + cookies: Cookies +): Promise | null> { + const token = cookies.get('auth_token'); + const refreshToken = cookies.get('refresh_token'); + + // 1. Try the JWT + if (token) { + const res = await fetch(`${BACKEND_URL}/api/auth/me`, { + headers: { Authorization: `Bearer ${token}` } + }); + if (res.ok) return res.json(); + } + + // 2. JWT expired or missing — try refresh + if (refreshToken) { + const res = await fetch(`${BACKEND_URL}/api/auth/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken }) + }); + + if (res.ok) { + const data = await res.json(); + + // Set the rotated tokens + cookies.set('auth_token', data.token, { ...COOKIE_OPTS, maxAge: 3600 }); + cookies.set('refresh_token', data.refreshToken, { + ...COOKIE_OPTS, + maxAge: 90 * 24 * 60 * 60 + }); + + // Fetch user claims with the new JWT + const meRes = await fetch(`${BACKEND_URL}/api/auth/me`, { + headers: { Authorization: `Bearer ${data.token}` } + }); + if (meRes.ok) return meRes.json(); + } + } + + // 3. Both expired — clean up + cookies.delete('auth_token', { path: '/' }); + cookies.delete('refresh_token', { path: '/' }); + return null; +} diff --git a/frontend/src/routes/+error.svelte b/frontend/src/routes/+error.svelte new file mode 100644 index 0000000..059e2d4 --- /dev/null +++ b/frontend/src/routes/+error.svelte @@ -0,0 +1,100 @@ + + +
+
+

{page.status === 404 ? 'How did you get here?' : page.status}

+

{page.error?.message ?? 'Something went wrong'}

+ HTTP {page.status} cat + ← Back to safety +
+
+ + diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte new file mode 100644 index 0000000..783a985 --- /dev/null +++ b/frontend/src/routes/+layout.svelte @@ -0,0 +1,92 @@ + + + + Beest + + +{#if impersonating} + +{/if} + +{@render children()} + + diff --git a/frontend/src/routes/+page.server.ts b/frontend/src/routes/+page.server.ts new file mode 100644 index 0000000..76cfa30 --- /dev/null +++ b/frontend/src/routes/+page.server.ts @@ -0,0 +1,8 @@ +import { getAuthenticatedUser } from '$lib/server/auth'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ cookies }) => { + const user = await getAuthenticatedUser(cookies); + if (!user) return { authenticated: false }; + return { authenticated: true, userName: user.name ?? user.nickname ?? null }; +}; diff --git a/beest/src/routes/+page.svelte b/frontend/src/routes/+page.svelte similarity index 68% rename from beest/src/routes/+page.svelte rename to frontend/src/routes/+page.svelte index 62aface..7444f1f 100644 --- a/beest/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -15,13 +15,33 @@ + +{@render children()} diff --git a/beest/src/routes/FAQ/+page.svelte b/frontend/src/routes/FAQ/+page.svelte similarity index 68% rename from beest/src/routes/FAQ/+page.svelte rename to frontend/src/routes/FAQ/+page.svelte index b07d0f4..f303472 100644 --- a/beest/src/routes/FAQ/+page.svelte +++ b/frontend/src/routes/FAQ/+page.svelte @@ -14,38 +14,47 @@ - -
- -

Frequently Asked Questions

-

Got questions about Beest? We've got answers. If you need more help, hop into the Beest channel on Hack Club Slack or email euan@hackclub.com

+

I'm sure you have lots of questions! Below is the most common ones I see, but if you need more help please email beest@hackclub.com or use the dedicated slack channel #beest-help

{#each faqs as faq, i (faq.q)}
diff --git a/frontend/src/routes/admin/DauChart.svelte b/frontend/src/routes/admin/DauChart.svelte new file mode 100644 index 0000000..28a52a5 --- /dev/null +++ b/frontend/src/routes/admin/DauChart.svelte @@ -0,0 +1,237 @@ + + +
+
+ Daily Active Users + {todayCount ?? '…'} today +
+
+ {#if error} +

Failed to load.

+ {:else if !payload} +

Loading…

+ {:else if points.length === 0} +

No data.

+ {:else} + shortDate(v), + ticks: 6, + tickLabelProps: { class: 'dau-tick' } + }, + yAxis: { + format: (v: number) => String(v), + ticks: 4, + tickLabelProps: { class: 'dau-tick' } + }, + grid: { class: 'dau-grid' }, + rule: { class: 'dau-rule' }, + spline: { class: 'dau-line' }, + points: { class: 'dau-dot', r: 3.5 }, + highlight: { points: { class: 'dau-dot-hover', r: 5 } }, + tooltip: { + root: { + classes: { root: 'dau-tooltip', container: 'dau-tooltip-inner' } + }, + header: { + format: (v: Date | string) => longDate(new Date(v as Date)), + classes: { root: 'dau-tooltip-header' } + }, + item: { + classes: { root: 'dau-tooltip-item', label: 'dau-tooltip-label' } + } + } + }} + /> + {/if} +
+
+ + diff --git a/frontend/src/routes/admin/SignupsChart.svelte b/frontend/src/routes/admin/SignupsChart.svelte new file mode 100644 index 0000000..abc1b31 --- /dev/null +++ b/frontend/src/routes/admin/SignupsChart.svelte @@ -0,0 +1,219 @@ + + +
+
+ Cumulative Sign-ups + {totalSignups ?? '…'} total +
+
+ {#if error} +

Failed to load.

+ {:else if !payload} +

Loading…

+ {:else if points.length === 0} +

No data.

+ {:else} + shortDate(v), + ticks: 6, + tickLabelProps: { class: 'signups-tick' } + }, + yAxis: { + format: (v: number) => String(v), + ticks: 4, + tickLabelProps: { class: 'signups-tick' } + }, + grid: { class: 'signups-grid' }, + rule: { class: 'signups-rule' }, + spline: { class: 'signups-line' }, + highlight: { points: { class: 'signups-dot-hover', r: 5 } }, + tooltip: { + root: { + classes: { root: 'signups-tooltip', container: 'signups-tooltip-inner' } + }, + header: { + format: (v: Date | string) => longDate(new Date(v as Date)), + classes: { root: 'signups-tooltip-header' } + }, + item: { + classes: { root: 'signups-tooltip-item', label: 'signups-tooltip-label' } + } + } + }} + /> + {/if} +
+
+ + diff --git a/frontend/src/routes/admin/UserFunnel.svelte b/frontend/src/routes/admin/UserFunnel.svelte new file mode 100644 index 0000000..31fd2ff --- /dev/null +++ b/frontend/src/routes/admin/UserFunnel.svelte @@ -0,0 +1,201 @@ + + +
+
+ User Funnel +
+ {#if error} +

Failed to load.

+ {:else if !payload} +

Loading…

+ {:else} +
+ {#each stages as stage, i} + {@const leftCount = i === 0 ? stage.count : stages[i - 1].count} +
+
{i + 1}. {stage.label}
+
+ + + +
+ +
+ {/each} +
+ {/if} +
+ + diff --git a/frontend/src/routes/admin/audit/+page.server.ts b/frontend/src/routes/admin/audit/+page.server.ts new file mode 100644 index 0000000..0e0740c --- /dev/null +++ b/frontend/src/routes/admin/audit/+page.server.ts @@ -0,0 +1,21 @@ +import { redirect } from '@sveltejs/kit'; +import { getAuthenticatedUser } from '$lib/server/auth'; +import { env } from '$env/dynamic/private'; +import type { PageServerLoad } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +// Second-pass review is open to Super Admin and Fraud Reviewer. +export const load: PageServerLoad = async ({ cookies }) => { + const user = await getAuthenticatedUser(cookies); + if (!user) redirect(302, '/'); + + const token = cookies.get('auth_token'); + const adminRes = await fetch(`${BACKEND_URL}/api/auth/scope?scope=audit`, { + headers: { Authorization: `Bearer ${token}` } + }); + if (!adminRes.ok) redirect(302, '/home'); + const data = await adminRes.json(); + + return { user, role: data.perms }; +}; diff --git a/frontend/src/routes/admin/audit/+page.svelte b/frontend/src/routes/admin/audit/+page.svelte new file mode 100644 index 0000000..65407f1 --- /dev/null +++ b/frontend/src/routes/admin/audit/+page.svelte @@ -0,0 +1,944 @@ + + +
+
+
+ ← admin +

Second-pass review

+
+ +
+ + {#if loading} +
Loading queue…
+ {:else if loadError} +
Failed to load: {loadError}
+ {:else if queue.length === 0} +
+

Queue clear

+

No projects are awaiting second-pass review.

+ {#if isSuperAdmin} +

Pull in up to 10 of the oldest unreviewed projects as one-shot reviews — they skip the first-pass and are decided here, by you alone.

+ + {#if loadUnreviewedError} +
{loadUnreviewedError}
+ {/if} + {/if} +
+ {:else if current} + {#key current.id} +
+
+
+
+

{current.name}

+ {current.projectType} + {#if current.isUpdate}update{/if} + {#if current.isOneShot}one-shot{/if} +
+

{current.description}

+ +
+ + {#if current.screenshot1Url || current.screenshot2Url} +
+
+ {#if current.screenshot1Url}screenshot 1{/if} + {#if current.screenshot2Url}screenshot 2{/if} +
+
+ {/if} + +
+
+
Owner
{current.owner?.nickname || current.owner?.name || '—'}{#if current.owner?.slackId} · {current.owner.slackId}{/if}
+ {#if current.owner?.email}
Email
{current.owner.email}
{/if} +
Hackatime
{current.owner?.hackatimeConnected ? 'linked' : 'not linked'}{#if current.hackatimeProjectNames.length} · {current.hackatimeProjectNames.join(', ')}{/if}
+
Approved hours (pending)
{current.overrideHours}h · {current.pipesGranted} pipes granted
+ {#if current.aiUse}
AI use
{current.aiUse}
{/if} + {#if current.otherHcProgram}
Other HC program
{current.otherHcProgram}
{/if} + {#if current.submission?.changeDescription}
Change description
{current.submission.changeDescription}
{/if} +
Submitted
{fmtDate(current.submission?.createdAt ?? current.createdAt)} ({ago(current.createdAt)})
+
+
+ + {#if trust && (trust.trustLevel === 'red' || trust.trustLevel === 'yellow' || trust.emailMismatch)} +
+

Hackatime warnings

+ {#if trust.trustLevel === 'red'} +
Hackatime trust: RED — Hackatime has flagged this user as untrusted.
+ {:else if trust.trustLevel === 'yellow'} +
Hackatime trust: yellow — Hackatime has flagged this user for caution.
+ {/if} + {#if trust.emailMismatch} +
Account mismatch — the linked Hackatime account's email doesn't include this builder's email. Likely a shared/alt account.
+ {/if} +
+ {:else if trustLoading} +
+

Checking Hackatime warnings…

+
+ {/if} + +
+

Original approval reason

+ {#if current.originalApproval} +

by {current.originalApproval.reviewerName ?? 'reviewer'} · {fmtDate(current.originalApproval.createdAt)}

+
{current.originalApproval.overrideJustification || '(no justification recorded)'}
+ {#if current.originalApproval.internalNote}

Internal note: {current.originalApproval.internalNote}

{/if} + {:else if current.isOneShot} +

This is a one-shot review pulled from the unreviewed queue. There is no first-pass approval — your decision is final.

+ {:else} +

No approval review record found.

+ {/if} +
+ + {#if current.priorSubmissions.length > 0} +
+

Prior submissions ({current.priorSubmissions.length})

+
    + {#each current.priorSubmissions as p (p.id)} +
  1. +
    + {p.review?.status ?? p.status} + {fmtDate(p.createdAt)} + {#if p.overrideHours != null && p.overrideHours > 0}+{p.overrideHours}h{#if p.pipesGranted != null && p.pipesGranted > 0} · {p.pipesGranted} pipes{/if}{/if} +
    + {#if p.changeDescription} +

    Change description: {p.changeDescription}

    + {/if} + {#if p.review} +

    reviewed by {p.review.reviewerName ?? 'reviewer'} · {fmtDate(p.review.createdAt)}

    + {#if p.review.overrideJustification} +
    {p.review.overrideJustification}
    + {/if} + {#if p.review.feedback} +

    Feedback to user: {p.review.feedback}

    + {/if} + {#if p.review.internalNote} +

    Internal note: {p.review.internalNote}

    + {/if} + {:else} +

    No review record found for this submission.

    + {/if} +
  2. + {/each} +
+
+ {/if} + +
+ +
+ +
+

Hackatime heartbeats

+ (filterInfo = info)} + onComplete={(d) => (anomalies = d.error ? null : d.anomalies)} + /> +
+ + {#if anomalies} +
+

Anomaly signals

+ {#if anomalies.autoClicker.severity === 'none' && anomalies.macroTyper.severity === 'none' && anomalies.offRepo.count === 0} +
no anomaly signals
+ {:else} + {#if anomalies.autoClicker.severity !== 'none'} +
Auto-clicker — {anomalies.autoClicker.evidence}
+ {/if} + {#if anomalies.macroTyper.severity !== 'none'} +
Macro-typer — {anomalies.macroTyper.evidence}
+ {/if} + {#if anomalies.offRepo.count > 0} +
Off-repo — {anomalies.offRepo.count} heartbeats
+ {/if} + {/if} +
+ {/if} + +
+
+ + + + {#if isSuperAdmin} + + {/if} +
+ + {#if submitError}
{submitError}
{/if} + + {#if action === 'approve'} + +
+ { + approveHoursTouched = true; + const v = (e.currentTarget as HTMLInputElement).valueAsNumber; + approveHoursInput = Number.isFinite(v) ? v : null; + }} + /> + {#if current.isOneShot} + no first review — set the final approved hours yourself{#if trust?.totalHours != null} (Hackatime: {trust.totalHours.toFixed(1)}h){/if} + {:else if scaledHours !== baseHours} + first review: {baseHours}h → after exclusions {scaledHours}h + {:else} + first review approved {baseHours}h + {/if} +
+ + +
+ { + internalHoursTouched = true; + const v = (e.currentTarget as HTMLInputElement).valueAsNumber; + internalHoursInput = Number.isFinite(v) ? v : null; + }} + /> + {#if current.isOneShot} + defaults to the user-facing value — adjust if you want a different number on Airtable + {:else if baseInternalHours !== baseHours} + first review set {baseInternalHours}h internal (vs {baseHours}h user-facing) + {:else} + first review set {baseInternalHours}h internal + {/if} +
+ + + + + {#if current.isOneShot} + + + {/if} + +
+
Pipe math preview — driven by user-facing hours, NOT internal hours
+ {#if isResubmission} + {#if priorCumulative != null && submissionDelta != null && submissionDelta > 0} +
Prior cumulative{priorCumulative}h
+
First-pass delta this submission+{submissionDelta}h
+ {/if} +
User-facing hours you're setting{effectiveApproveHours}h
+
Pipes already paid on this project{pipesAlreadyPaid}
+
New pipes from this approvalfloor({effectiveApproveHours}) − {pipesAlreadyPaid} = +{previewNewPipes}
+ {:else} +
User-facing hours you're setting{effectiveApproveHours}h
+
New pipes from this approvalfloor({effectiveApproveHours}) = +{previewNewPipes}
+ {/if} +

Internal hours go only to Airtable's "Override Hours Spent" field — they don't affect pipes. Backend grants floor(sum of override_hours) − sum(pipes_granted) across all the user's projects, so cross-project fractional remainders can shift the actual grant by ±1 pipe.

+
+ + + {:else if action === 'rereview'} +

{current.isOneShot ? 'Releases the project back to the first-review queue. The user is not notified.' : 'Sends the project back to the first-review queue. The user is NOT notified — this feedback is for the first reviewer.'}

+ + + + {:else if action === 'reject'} +

Rejects the project. The user sees this feedback as a regular "changes needed".

+ + + + {:else} +

Bans the user and rejects the project. Use sparingly — Super Admin only.

+ + + + {/if} +
+
+
+ {/key} + {/if} +
+ + diff --git a/frontend/src/routes/api/admin/audit/[id]/activity/+server.ts b/frontend/src/routes/api/admin/audit/[id]/activity/+server.ts new file mode 100644 index 0000000..9098b5b --- /dev/null +++ b/frontend/src/routes/api/admin/audit/[id]/activity/+server.ts @@ -0,0 +1,40 @@ +import { env } from '$env/dynamic/private'; +import { tryRefreshToken } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +// Streaming proxy — the backend emits NDJSON progress events, so unlike the +// JSON-buffering proxyWithRefresh we pipe the response body straight through. +export const GET: RequestHandler = async ({ cookies, params }) => { + const url = `${BACKEND_URL}/api/admin/audit/${params.id}/activity`; + + let token = cookies.get('auth_token'); + if (!token) { + token = (await tryRefreshToken(cookies)) ?? undefined; + if (!token) return new Response('Not authenticated', { status: 401 }); + } + + let res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }); + if (res.status === 401) { + const newToken = await tryRefreshToken(cookies); + if (!newToken) return new Response('Not authenticated', { status: 401 }); + res = await fetch(url, { headers: { Authorization: `Bearer ${newToken}` } }); + } + + if (!res.ok || !res.body) { + return new Response( + JSON.stringify({ type: 'error', error: `backend ${res.status}` }) + '\n', + { status: res.status, headers: { 'Content-Type': 'application/x-ndjson' } } + ); + } + + return new Response(res.body, { + status: 200, + headers: { + 'Content-Type': 'application/x-ndjson; charset=utf-8', + 'Cache-Control': 'no-cache', + 'X-Accel-Buffering': 'no' + } + }); +}; diff --git a/frontend/src/routes/api/admin/audit/[id]/decision/+server.ts b/frontend/src/routes/api/admin/audit/[id]/decision/+server.ts new file mode 100644 index 0000000..c9aee96 --- /dev/null +++ b/frontend/src/routes/api/admin/audit/[id]/decision/+server.ts @@ -0,0 +1,18 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const POST: RequestHandler = async ({ cookies, params, request }) => { + const body = await request.text(); + return proxyWithRefresh( + cookies, + `${BACKEND_URL}/api/admin/audit/${params.id}/decision`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body + } + ); +}; diff --git a/frontend/src/routes/api/admin/audit/load-unreviewed/+server.ts b/frontend/src/routes/api/admin/audit/load-unreviewed/+server.ts new file mode 100644 index 0000000..c475f75 --- /dev/null +++ b/frontend/src/routes/api/admin/audit/load-unreviewed/+server.ts @@ -0,0 +1,12 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const POST: RequestHandler = async ({ cookies }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/admin/audit/load-unreviewed`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); +}; diff --git a/frontend/src/routes/api/admin/audit/queue/+server.ts b/frontend/src/routes/api/admin/audit/queue/+server.ts new file mode 100644 index 0000000..aa5468a --- /dev/null +++ b/frontend/src/routes/api/admin/audit/queue/+server.ts @@ -0,0 +1,9 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const GET: RequestHandler = async ({ cookies }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/admin/audit/queue`); +}; diff --git a/frontend/src/routes/api/admin/hcb/card-grant/+server.ts b/frontend/src/routes/api/admin/hcb/card-grant/+server.ts new file mode 100644 index 0000000..a2f1587 --- /dev/null +++ b/frontend/src/routes/api/admin/hcb/card-grant/+server.ts @@ -0,0 +1,14 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const POST: RequestHandler = async ({ cookies, request }) => { + const body = await request.json(); + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/admin/hcb/card-grant`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); +}; diff --git a/frontend/src/routes/api/admin/hcb/connect/+server.ts b/frontend/src/routes/api/admin/hcb/connect/+server.ts new file mode 100644 index 0000000..2cd706a --- /dev/null +++ b/frontend/src/routes/api/admin/hcb/connect/+server.ts @@ -0,0 +1,52 @@ +import { redirect } from '@sveltejs/kit'; +import { env } from '$env/dynamic/private'; +import { tryRefreshToken } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +/** + * Starts the HCB OAuth connect flow. Asks the backend (Super Admin guarded) to + * mint an authorize URL + state, stores the state in an httpOnly cookie for CSRF + * verification on the callback, then redirects the browser to HCB. + */ +export const GET: RequestHandler = async ({ cookies }) => { + let token = cookies.get('auth_token'); + if (!token) { + token = (await tryRefreshToken(cookies)) ?? undefined; + if (!token) redirect(302, '/'); + } + + let res = await fetch(`${BACKEND_URL}/api/admin/hcb/connect`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` } + }); + + if (res.status === 401) { + const refreshed = await tryRefreshToken(cookies); + if (refreshed) { + res = await fetch(`${BACKEND_URL}/api/admin/hcb/connect`, { + method: 'POST', + headers: { Authorization: `Bearer ${refreshed}` } + }); + } + } + + if (!res.ok) { + return new Response('Failed to start HCB connection (are you a Super Admin?)', { + status: 502 + }); + } + + const { url, state } = await res.json(); + + cookies.set('hcb_oauth_state', state, { + path: '/', + httpOnly: true, + sameSite: 'lax', + secure: env.NODE_ENV === 'production', + maxAge: 600 + }); + + redirect(302, url); +}; diff --git a/frontend/src/routes/api/admin/hcb/prefill/[orderId]/+server.ts b/frontend/src/routes/api/admin/hcb/prefill/[orderId]/+server.ts new file mode 100644 index 0000000..d80287d --- /dev/null +++ b/frontend/src/routes/api/admin/hcb/prefill/[orderId]/+server.ts @@ -0,0 +1,11 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const GET: RequestHandler = async ({ cookies, params }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/admin/hcb/prefill/${params.orderId}`, { + method: 'GET' + }); +}; diff --git a/frontend/src/routes/api/admin/hcb/status/+server.ts b/frontend/src/routes/api/admin/hcb/status/+server.ts new file mode 100644 index 0000000..6a28518 --- /dev/null +++ b/frontend/src/routes/api/admin/hcb/status/+server.ts @@ -0,0 +1,9 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const GET: RequestHandler = async ({ cookies }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/admin/hcb/status`, { method: 'GET' }); +}; diff --git a/frontend/src/routes/api/admin/news/+server.ts b/frontend/src/routes/api/admin/news/+server.ts new file mode 100644 index 0000000..8821b9b --- /dev/null +++ b/frontend/src/routes/api/admin/news/+server.ts @@ -0,0 +1,18 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const GET: RequestHandler = async ({ cookies }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/admin/news`); +}; + +export const POST: RequestHandler = async ({ cookies, request }) => { + const body = await request.json(); + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/admin/news`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); +}; diff --git a/frontend/src/routes/api/admin/news/[id]/+server.ts b/frontend/src/routes/api/admin/news/[id]/+server.ts new file mode 100644 index 0000000..6b7b6bf --- /dev/null +++ b/frontend/src/routes/api/admin/news/[id]/+server.ts @@ -0,0 +1,20 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const PATCH: RequestHandler = async ({ cookies, params, request }) => { + const body = await request.json(); + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/admin/news/${params.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); +}; + +export const DELETE: RequestHandler = async ({ cookies, params }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/admin/news/${params.id}`, { + method: 'DELETE' + }); +}; diff --git a/frontend/src/routes/api/admin/orders/+server.ts b/frontend/src/routes/api/admin/orders/+server.ts new file mode 100644 index 0000000..6c8c830 --- /dev/null +++ b/frontend/src/routes/api/admin/orders/+server.ts @@ -0,0 +1,11 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const GET: RequestHandler = async ({ cookies, url }) => { + const params = url.searchParams.toString(); + const qs = params ? `?${params}` : ''; + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/admin/orders${qs}`); +}; diff --git a/frontend/src/routes/api/admin/orders/[id]/detail/+server.ts b/frontend/src/routes/api/admin/orders/[id]/detail/+server.ts new file mode 100644 index 0000000..1eb510e --- /dev/null +++ b/frontend/src/routes/api/admin/orders/[id]/detail/+server.ts @@ -0,0 +1,11 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const GET: RequestHandler = async ({ cookies, params }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/admin/orders/${params.id}/detail`, { + method: 'GET' + }); +}; diff --git a/frontend/src/routes/api/admin/orders/[id]/fulfill/+server.ts b/frontend/src/routes/api/admin/orders/[id]/fulfill/+server.ts new file mode 100644 index 0000000..7744139 --- /dev/null +++ b/frontend/src/routes/api/admin/orders/[id]/fulfill/+server.ts @@ -0,0 +1,11 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const POST: RequestHandler = async ({ cookies, params }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/admin/orders/${params.id}/fulfill`, { + method: 'POST' + }); +}; diff --git a/frontend/src/routes/api/admin/orders/[id]/merge/+server.ts b/frontend/src/routes/api/admin/orders/[id]/merge/+server.ts new file mode 100644 index 0000000..57c0d26 --- /dev/null +++ b/frontend/src/routes/api/admin/orders/[id]/merge/+server.ts @@ -0,0 +1,11 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const POST: RequestHandler = async ({ cookies, params }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/admin/orders/${params.id}/merge`, { + method: 'POST' + }); +}; diff --git a/frontend/src/routes/api/admin/orders/[id]/message/+server.ts b/frontend/src/routes/api/admin/orders/[id]/message/+server.ts new file mode 100644 index 0000000..c8658ee --- /dev/null +++ b/frontend/src/routes/api/admin/orders/[id]/message/+server.ts @@ -0,0 +1,14 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const POST: RequestHandler = async ({ cookies, params, request }) => { + const body = await request.json(); + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/admin/orders/${params.id}/message`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); +}; diff --git a/frontend/src/routes/api/admin/orders/[id]/refund/+server.ts b/frontend/src/routes/api/admin/orders/[id]/refund/+server.ts new file mode 100644 index 0000000..47ac197 --- /dev/null +++ b/frontend/src/routes/api/admin/orders/[id]/refund/+server.ts @@ -0,0 +1,11 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const POST: RequestHandler = async ({ cookies, params }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/admin/orders/${params.id}/refund`, { + method: 'POST' + }); +}; diff --git a/frontend/src/routes/api/admin/projects/+server.ts b/frontend/src/routes/api/admin/projects/+server.ts new file mode 100644 index 0000000..ab7b828 --- /dev/null +++ b/frontend/src/routes/api/admin/projects/+server.ts @@ -0,0 +1,9 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const GET: RequestHandler = async ({ cookies }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/admin/projects`); +}; diff --git a/frontend/src/routes/api/admin/projects/[id]/devlogs/+server.ts b/frontend/src/routes/api/admin/projects/[id]/devlogs/+server.ts new file mode 100644 index 0000000..809856b --- /dev/null +++ b/frontend/src/routes/api/admin/projects/[id]/devlogs/+server.ts @@ -0,0 +1,9 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const GET: RequestHandler = async ({ cookies, params }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/admin/projects/${params.id}/devlogs`); +}; diff --git a/frontend/src/routes/api/admin/projects/[id]/hackatime/+server.ts b/frontend/src/routes/api/admin/projects/[id]/hackatime/+server.ts new file mode 100644 index 0000000..9b0dd2c --- /dev/null +++ b/frontend/src/routes/api/admin/projects/[id]/hackatime/+server.ts @@ -0,0 +1,9 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const GET: RequestHandler = async ({ cookies, params }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/admin/projects/${params.id}/hackatime`); +}; diff --git a/frontend/src/routes/api/admin/projects/[id]/lapse/+server.ts b/frontend/src/routes/api/admin/projects/[id]/lapse/+server.ts new file mode 100644 index 0000000..52f6aea --- /dev/null +++ b/frontend/src/routes/api/admin/projects/[id]/lapse/+server.ts @@ -0,0 +1,9 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const GET: RequestHandler = async ({ cookies, params }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/admin/projects/${params.id}/lapse`); +}; diff --git a/frontend/src/routes/api/admin/projects/[id]/resync-airtable/+server.ts b/frontend/src/routes/api/admin/projects/[id]/resync-airtable/+server.ts new file mode 100644 index 0000000..ec3b935 --- /dev/null +++ b/frontend/src/routes/api/admin/projects/[id]/resync-airtable/+server.ts @@ -0,0 +1,11 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const POST: RequestHandler = async ({ cookies, params }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/admin/projects/${params.id}/resync-airtable`, { + method: 'POST', + }); +}; diff --git a/frontend/src/routes/api/admin/projects/[id]/review/+server.ts b/frontend/src/routes/api/admin/projects/[id]/review/+server.ts new file mode 100644 index 0000000..16db490 --- /dev/null +++ b/frontend/src/routes/api/admin/projects/[id]/review/+server.ts @@ -0,0 +1,14 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const POST: RequestHandler = async ({ cookies, params, request }) => { + const body = await request.text(); + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/admin/projects/${params.id}/review`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }); +}; diff --git a/frontend/src/routes/api/admin/projects/[id]/reviews/+server.ts b/frontend/src/routes/api/admin/projects/[id]/reviews/+server.ts new file mode 100644 index 0000000..28731f2 --- /dev/null +++ b/frontend/src/routes/api/admin/projects/[id]/reviews/+server.ts @@ -0,0 +1,9 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const GET: RequestHandler = async ({ cookies, params }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/admin/projects/${params.id}/reviews`); +}; diff --git a/frontend/src/routes/api/admin/review-leaderboard/+server.ts b/frontend/src/routes/api/admin/review-leaderboard/+server.ts new file mode 100644 index 0000000..a4cf1a1 --- /dev/null +++ b/frontend/src/routes/api/admin/review-leaderboard/+server.ts @@ -0,0 +1,10 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const GET: RequestHandler = async ({ cookies, url }) => { + const window = url.searchParams.get('window') ?? '7d'; + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/admin/review-leaderboard?window=${encodeURIComponent(window)}`); +}; diff --git a/frontend/src/routes/api/admin/shop/+server.ts b/frontend/src/routes/api/admin/shop/+server.ts new file mode 100644 index 0000000..9e5cd42 --- /dev/null +++ b/frontend/src/routes/api/admin/shop/+server.ts @@ -0,0 +1,18 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const GET: RequestHandler = async ({ cookies }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/admin/shop`); +}; + +export const POST: RequestHandler = async ({ cookies, request }) => { + const body = await request.json(); + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/admin/shop`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); +}; diff --git a/frontend/src/routes/api/admin/shop/[id]/+server.ts b/frontend/src/routes/api/admin/shop/[id]/+server.ts new file mode 100644 index 0000000..ba8bfb9 --- /dev/null +++ b/frontend/src/routes/api/admin/shop/[id]/+server.ts @@ -0,0 +1,20 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const PATCH: RequestHandler = async ({ cookies, params, request }) => { + const body = await request.json(); + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/admin/shop/${params.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); +}; + +export const DELETE: RequestHandler = async ({ cookies, params }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/admin/shop/${params.id}`, { + method: 'DELETE' + }); +}; diff --git a/frontend/src/routes/api/admin/shop/reorder/+server.ts b/frontend/src/routes/api/admin/shop/reorder/+server.ts new file mode 100644 index 0000000..724698e --- /dev/null +++ b/frontend/src/routes/api/admin/shop/reorder/+server.ts @@ -0,0 +1,14 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const PATCH: RequestHandler = async ({ cookies, request }) => { + const body = await request.json(); + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/admin/shop/reorder`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); +}; diff --git a/frontend/src/routes/api/admin/stats/dau/+server.ts b/frontend/src/routes/api/admin/stats/dau/+server.ts new file mode 100644 index 0000000..c315f6d --- /dev/null +++ b/frontend/src/routes/api/admin/stats/dau/+server.ts @@ -0,0 +1,9 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const GET: RequestHandler = async ({ cookies }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/admin/stats/dau`); +}; diff --git a/frontend/src/routes/api/admin/stats/dau/history/+server.ts b/frontend/src/routes/api/admin/stats/dau/history/+server.ts new file mode 100644 index 0000000..abbf4b5 --- /dev/null +++ b/frontend/src/routes/api/admin/stats/dau/history/+server.ts @@ -0,0 +1,9 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const GET: RequestHandler = async ({ cookies }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/admin/stats/dau/history`); +}; diff --git a/frontend/src/routes/api/admin/stats/funnel/+server.ts b/frontend/src/routes/api/admin/stats/funnel/+server.ts new file mode 100644 index 0000000..93f8c32 --- /dev/null +++ b/frontend/src/routes/api/admin/stats/funnel/+server.ts @@ -0,0 +1,9 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const GET: RequestHandler = async ({ cookies }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/admin/stats/funnel`); +}; diff --git a/frontend/src/routes/api/admin/stats/signups/+server.ts b/frontend/src/routes/api/admin/stats/signups/+server.ts new file mode 100644 index 0000000..8ade867 --- /dev/null +++ b/frontend/src/routes/api/admin/stats/signups/+server.ts @@ -0,0 +1,9 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const GET: RequestHandler = async ({ cookies }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/admin/stats/signups`); +}; diff --git a/frontend/src/routes/api/admin/stats/unreviewed-hours/+server.ts b/frontend/src/routes/api/admin/stats/unreviewed-hours/+server.ts new file mode 100644 index 0000000..97f6800 --- /dev/null +++ b/frontend/src/routes/api/admin/stats/unreviewed-hours/+server.ts @@ -0,0 +1,9 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const GET: RequestHandler = async ({ cookies }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/admin/stats/unreviewed-hours`); +}; diff --git a/frontend/src/routes/api/admin/users/+server.ts b/frontend/src/routes/api/admin/users/+server.ts new file mode 100644 index 0000000..35a7680 --- /dev/null +++ b/frontend/src/routes/api/admin/users/+server.ts @@ -0,0 +1,9 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const GET: RequestHandler = async ({ cookies }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/admin/users`); +}; diff --git a/frontend/src/routes/api/admin/users/[id]/+server.ts b/frontend/src/routes/api/admin/users/[id]/+server.ts new file mode 100644 index 0000000..d426012 --- /dev/null +++ b/frontend/src/routes/api/admin/users/[id]/+server.ts @@ -0,0 +1,9 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const GET: RequestHandler = async ({ cookies, params }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/admin/users/${params.id}`); +}; diff --git a/frontend/src/routes/api/admin/users/[id]/ban/+server.ts b/frontend/src/routes/api/admin/users/[id]/ban/+server.ts new file mode 100644 index 0000000..bc903ea --- /dev/null +++ b/frontend/src/routes/api/admin/users/[id]/ban/+server.ts @@ -0,0 +1,11 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const POST: RequestHandler = async ({ cookies, params }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/admin/users/${params.id}/ban`, { + method: 'POST' + }); +}; diff --git a/frontend/src/routes/api/admin/users/[id]/impersonate/+server.ts b/frontend/src/routes/api/admin/users/[id]/impersonate/+server.ts new file mode 100644 index 0000000..019f936 --- /dev/null +++ b/frontend/src/routes/api/admin/users/[id]/impersonate/+server.ts @@ -0,0 +1,66 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +const COOKIE_OPTS = { + path: '/', + httpOnly: true, + sameSite: 'lax' as const, + secure: env.NODE_ENV === 'production' +}; + +/** + * Starts impersonation: saves admin tokens, sets impersonation JWT. + */ +export const POST: RequestHandler = async ({ cookies, params }) => { + // 1. Stash the admin's current tokens BEFORE proxying — proxyWithRefresh + // auto-sets auth_token when the response contains a token field. + const adminToken = cookies.get('auth_token'); + const adminRefresh = cookies.get('refresh_token'); + + // 2. Proxy the impersonate request to backend + const res = await proxyWithRefresh( + cookies, + `${BACKEND_URL}/api/admin/users/${params.id}/impersonate`, + { method: 'POST' } + ); + + if (!res.ok) return res; + + const data = await res.json(); + if (!data.token) { + return new Response(JSON.stringify({ error: 'No token returned' }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } + + // 3. Save the stashed admin tokens so we can restore them later + if (adminToken) { + cookies.set('admin_auth_token', adminToken, { ...COOKIE_OPTS, maxAge: 3600 }); + } + if (adminRefresh) { + cookies.set('admin_refresh_token', adminRefresh, { ...COOKIE_OPTS, maxAge: 90 * 24 * 60 * 60 }); + } + + // 4. Ensure the impersonation JWT is set (proxyWithRefresh may have already done this) + cookies.set('auth_token', data.token, { ...COOKIE_OPTS, maxAge: 3600 }); + // No refresh token for impersonation — admin must end session before it expires + cookies.delete('refresh_token', { path: '/' }); + + // 4. Set a non-httpOnly cookie so the frontend can detect impersonation + cookies.set('impersonating', '1', { + path: '/', + httpOnly: false, + sameSite: 'lax', + secure: env.NODE_ENV === 'production', + maxAge: 3600 + }); + + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); +}; diff --git a/frontend/src/routes/api/admin/users/[id]/perms/+server.ts b/frontend/src/routes/api/admin/users/[id]/perms/+server.ts new file mode 100644 index 0000000..fbe82cd --- /dev/null +++ b/frontend/src/routes/api/admin/users/[id]/perms/+server.ts @@ -0,0 +1,14 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const PATCH: RequestHandler = async ({ cookies, params, request }) => { + const body = await request.json(); + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/admin/users/${params.id}/perms`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); +}; diff --git a/frontend/src/routes/api/admin/users/[id]/pipes/+server.ts b/frontend/src/routes/api/admin/users/[id]/pipes/+server.ts new file mode 100644 index 0000000..d70621f --- /dev/null +++ b/frontend/src/routes/api/admin/users/[id]/pipes/+server.ts @@ -0,0 +1,14 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const PATCH: RequestHandler = async ({ cookies, params, request }) => { + const body = await request.json(); + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/admin/users/${params.id}/pipes`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); +}; diff --git a/frontend/src/routes/api/audit-log/+server.ts b/frontend/src/routes/api/audit-log/+server.ts new file mode 100644 index 0000000..0b6953f --- /dev/null +++ b/frontend/src/routes/api/audit-log/+server.ts @@ -0,0 +1,9 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const GET: RequestHandler = async ({ cookies }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/audit-log`); +}; diff --git a/frontend/src/routes/api/auth/end-impersonate/+server.ts b/frontend/src/routes/api/auth/end-impersonate/+server.ts new file mode 100644 index 0000000..6043864 --- /dev/null +++ b/frontend/src/routes/api/auth/end-impersonate/+server.ts @@ -0,0 +1,42 @@ +import { env } from '$env/dynamic/private'; +import type { RequestHandler } from './$types'; + +const COOKIE_OPTS = { + path: '/', + httpOnly: true, + sameSite: 'lax' as const, + secure: env.NODE_ENV === 'production' +}; + +/** + * Ends impersonation: restores admin's original tokens. + */ +export const POST: RequestHandler = async ({ cookies }) => { + const adminToken = cookies.get('admin_auth_token'); + const adminRefresh = cookies.get('admin_refresh_token'); + + if (!adminToken && !adminRefresh) { + return new Response(JSON.stringify({ error: 'No admin session to restore' }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Restore admin tokens + if (adminToken) { + cookies.set('auth_token', adminToken, { ...COOKIE_OPTS, maxAge: 3600 }); + } + if (adminRefresh) { + cookies.set('refresh_token', adminRefresh, { ...COOKIE_OPTS, maxAge: 90 * 24 * 60 * 60 }); + } + + // Clean up impersonation cookies + cookies.delete('admin_auth_token', { path: '/' }); + cookies.delete('admin_refresh_token', { path: '/' }); + cookies.delete('impersonating', { path: '/' }); + + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); +}; diff --git a/frontend/src/routes/api/auth/gender/+server.ts b/frontend/src/routes/api/auth/gender/+server.ts new file mode 100644 index 0000000..67d56d5 --- /dev/null +++ b/frontend/src/routes/api/auth/gender/+server.ts @@ -0,0 +1,14 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const PATCH: RequestHandler = async ({ cookies, request }) => { + const body = await request.text(); + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/auth/gender`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body + }); +}; diff --git a/frontend/src/routes/api/auth/hackatime/start/+server.ts b/frontend/src/routes/api/auth/hackatime/start/+server.ts new file mode 100644 index 0000000..d1e5f35 --- /dev/null +++ b/frontend/src/routes/api/auth/hackatime/start/+server.ts @@ -0,0 +1,53 @@ +import { redirect } from '@sveltejs/kit'; +import { env } from '$env/dynamic/private'; +import { tryRefreshToken } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +// POST only — prevents CSRF via cross-site links (GET + sameSite:lax cookies) +export const POST: RequestHandler = async ({ cookies }) => { + let token = cookies.get('auth_token'); + if (!token) { + token = await tryRefreshToken(cookies) ?? undefined; + if (!token) { + return new Response('Not authenticated', { status: 401 }); + } + } + + const init = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }; + + let res = await fetch(`${BACKEND_URL}/api/hackatime/start`, init); + + if (res.status === 401) { + const newToken = await tryRefreshToken(cookies); + if (!newToken) { + return new Response('Not authenticated', { status: 401 }); + } + init.headers.Authorization = `Bearer ${newToken}`; + res = await fetch(`${BACKEND_URL}/api/hackatime/start`, init); + } + + if (!res.ok) { + return new Response('Failed to start Hackatime auth', { status: 502 }); + } + + const { url: authorizeUrl, state } = await res.json(); + + // Store state in httpOnly cookie for the callback + cookies.set('hackatime_state', state, { + path: '/', + httpOnly: true, + sameSite: 'lax' as const, + secure: env.NODE_ENV === 'production', + maxAge: 600 + }); + + redirect(302, authorizeUrl); +}; diff --git a/frontend/src/routes/api/auth/intent/+server.ts b/frontend/src/routes/api/auth/intent/+server.ts new file mode 100644 index 0000000..4598c76 --- /dev/null +++ b/frontend/src/routes/api/auth/intent/+server.ts @@ -0,0 +1,18 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const GET: RequestHandler = async ({ cookies }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/auth/intent`, { method: 'GET' }); +}; + +export const POST: RequestHandler = async ({ cookies, request }) => { + const body = await request.text(); + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/auth/intent`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body + }); +}; diff --git a/frontend/src/routes/api/auth/login/+server.ts b/frontend/src/routes/api/auth/login/+server.ts new file mode 100644 index 0000000..6e8cb0c --- /dev/null +++ b/frontend/src/routes/api/auth/login/+server.ts @@ -0,0 +1,41 @@ +import { redirect } from '@sveltejs/kit'; +import { env } from '$env/dynamic/private'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const GET: RequestHandler = async ({ url, cookies }) => { + const email = url.searchParams.get('email') ?? undefined; + + // Ask the backend to generate state + authorize URL + let res: Response; + try { + res = await fetch(`${BACKEND_URL}/api/auth/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }) + }); + } catch (err) { + console.error('Failed to reach backend at', BACKEND_URL, err); + return new Response('Backend unreachable', { status: 502 }); + } + + if (!res.ok) { + console.error('Backend returned', res.status, await res.text().catch(() => '')); + return new Response('Failed to start auth', { status: 502 }); + } + + const { url: authorizeUrl, state } = await res.json(); + // Store backend-generated values in httpOnly cookies for the callback + const cookieOpts = { + path: '/', + httpOnly: true, + sameSite: 'lax' as const, + secure: env.NODE_ENV === 'production', + maxAge: 600 + }; + + cookies.set('oauth_state', state, cookieOpts); + + redirect(302, authorizeUrl); +}; diff --git a/frontend/src/routes/api/auth/logout/+server.ts b/frontend/src/routes/api/auth/logout/+server.ts new file mode 100644 index 0000000..c514afb --- /dev/null +++ b/frontend/src/routes/api/auth/logout/+server.ts @@ -0,0 +1,24 @@ +import { redirect } from '@sveltejs/kit'; +import { env } from '$env/dynamic/private'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const GET: RequestHandler = async ({ cookies }) => { + const refreshToken = cookies.get('refresh_token'); + + // Invalidate the session in the DB + if (refreshToken) { + await fetch(`${BACKEND_URL}/api/auth/logout`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken }) + }); + } + + // Clear both auth cookies + cookies.delete('auth_token', { path: '/' }); + cookies.delete('refresh_token', { path: '/' }); + + redirect(302, '/'); +}; diff --git a/frontend/src/routes/api/auth/nickname/+server.ts b/frontend/src/routes/api/auth/nickname/+server.ts new file mode 100644 index 0000000..0fafbd8 --- /dev/null +++ b/frontend/src/routes/api/auth/nickname/+server.ts @@ -0,0 +1,14 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const PATCH: RequestHandler = async ({ cookies, request }) => { + const body = await request.text(); + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/auth/nickname`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body + }); +}; diff --git a/frontend/src/routes/api/auth/rsvp/+server.ts b/frontend/src/routes/api/auth/rsvp/+server.ts new file mode 100644 index 0000000..1c1987d --- /dev/null +++ b/frontend/src/routes/api/auth/rsvp/+server.ts @@ -0,0 +1,11 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const POST: RequestHandler = async ({ cookies }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/auth/rsvp`, { + method: 'POST' + }); +}; diff --git a/frontend/src/routes/api/auth/shipping-eligibility/+server.ts b/frontend/src/routes/api/auth/shipping-eligibility/+server.ts new file mode 100644 index 0000000..019ab7f --- /dev/null +++ b/frontend/src/routes/api/auth/shipping-eligibility/+server.ts @@ -0,0 +1,9 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const GET: RequestHandler = async ({ cookies }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/auth/shipping-eligibility`); +}; diff --git a/frontend/src/routes/api/cat/+server.ts b/frontend/src/routes/api/cat/+server.ts new file mode 100644 index 0000000..14431a3 --- /dev/null +++ b/frontend/src/routes/api/cat/+server.ts @@ -0,0 +1,22 @@ +import { env } from '$env/dynamic/private'; +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +const CAT_API_KEY = env.CAT_API_KEY ?? ''; + +export const GET: RequestHandler = async () => { + if (!CAT_API_KEY) { + return json({ url: null }); + } + try { + const res = await fetch( + 'https://api.thecatapi.com/v1/images/search?size=med&mime_types=jpg&format=json&has_breeds=true&order=RANDOM&page=0&limit=1', + { headers: { 'Content-Type': 'application/json', 'x-api-key': CAT_API_KEY } }, + ); + if (!res.ok) return json({ url: null }); + const data = await res.json(); + return json({ url: data?.[0]?.url ?? null }); + } catch { + return json({ url: null }); + } +}; diff --git a/frontend/src/routes/api/devlogs/+server.ts b/frontend/src/routes/api/devlogs/+server.ts new file mode 100644 index 0000000..e2bd40e --- /dev/null +++ b/frontend/src/routes/api/devlogs/+server.ts @@ -0,0 +1,18 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const GET: RequestHandler = async ({ cookies }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/devlogs`); +}; + +export const POST: RequestHandler = async ({ cookies, request }) => { + const body = await request.json(); + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/devlogs`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); +}; diff --git a/frontend/src/routes/api/devlogs/[id]/+server.ts b/frontend/src/routes/api/devlogs/[id]/+server.ts new file mode 100644 index 0000000..94962c8 --- /dev/null +++ b/frontend/src/routes/api/devlogs/[id]/+server.ts @@ -0,0 +1,11 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const DELETE: RequestHandler = async ({ cookies, params }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/devlogs/${params.id}`, { + method: 'DELETE' + }); +}; diff --git a/frontend/src/routes/api/hackatime/projects/+server.ts b/frontend/src/routes/api/hackatime/projects/+server.ts new file mode 100644 index 0000000..52f26ae --- /dev/null +++ b/frontend/src/routes/api/hackatime/projects/+server.ts @@ -0,0 +1,9 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const GET: RequestHandler = async ({ cookies }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/hackatime/projects`); +}; diff --git a/frontend/src/routes/api/leaderboard/+server.ts b/frontend/src/routes/api/leaderboard/+server.ts new file mode 100644 index 0000000..4f25be7 --- /dev/null +++ b/frontend/src/routes/api/leaderboard/+server.ts @@ -0,0 +1,10 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const GET: RequestHandler = async ({ cookies, url }) => { + const qs = url.search; + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/leaderboard${qs}`); +}; diff --git a/frontend/src/routes/api/news/+server.ts b/frontend/src/routes/api/news/+server.ts new file mode 100644 index 0000000..cef9fcd --- /dev/null +++ b/frontend/src/routes/api/news/+server.ts @@ -0,0 +1,9 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const GET: RequestHandler = async ({ cookies }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/news`); +}; diff --git a/frontend/src/routes/api/onboarding/mark-onboarded/+server.ts b/frontend/src/routes/api/onboarding/mark-onboarded/+server.ts new file mode 100644 index 0000000..0926886 --- /dev/null +++ b/frontend/src/routes/api/onboarding/mark-onboarded/+server.ts @@ -0,0 +1,11 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const POST: RequestHandler = async ({ cookies }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/onboarding/mark-onboarded`, { + method: 'POST' + }); +}; diff --git a/frontend/src/routes/api/onboarding/status/+server.ts b/frontend/src/routes/api/onboarding/status/+server.ts new file mode 100644 index 0000000..68e4f90 --- /dev/null +++ b/frontend/src/routes/api/onboarding/status/+server.ts @@ -0,0 +1,9 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const GET: RequestHandler = async ({ cookies }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/onboarding/status`); +}; diff --git a/frontend/src/routes/api/onboarding/sticker-link/+server.ts b/frontend/src/routes/api/onboarding/sticker-link/+server.ts new file mode 100644 index 0000000..4909c48 --- /dev/null +++ b/frontend/src/routes/api/onboarding/sticker-link/+server.ts @@ -0,0 +1,9 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const GET: RequestHandler = async ({ cookies }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/onboarding/sticker-link`); +}; diff --git a/frontend/src/routes/api/onboarding/two-emails/+server.ts b/frontend/src/routes/api/onboarding/two-emails/+server.ts new file mode 100644 index 0000000..ff6a8e2 --- /dev/null +++ b/frontend/src/routes/api/onboarding/two-emails/+server.ts @@ -0,0 +1,11 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const POST: RequestHandler = async ({ cookies }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/onboarding/two-emails`, { + method: 'POST' + }); +}; diff --git a/frontend/src/routes/api/projects/+server.ts b/frontend/src/routes/api/projects/+server.ts new file mode 100644 index 0000000..ed5e7ac --- /dev/null +++ b/frontend/src/routes/api/projects/+server.ts @@ -0,0 +1,18 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const POST: RequestHandler = async ({ cookies, request }) => { + const body = await request.json(); + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/projects`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); +}; + +export const GET: RequestHandler = async ({ cookies }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/projects`); +}; diff --git a/frontend/src/routes/api/projects/[id]/+server.ts b/frontend/src/routes/api/projects/[id]/+server.ts new file mode 100644 index 0000000..e4b3247 --- /dev/null +++ b/frontend/src/routes/api/projects/[id]/+server.ts @@ -0,0 +1,20 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const PATCH: RequestHandler = async ({ cookies, request, params }) => { + const body = await request.json(); + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/projects/${params.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); +}; + +export const DELETE: RequestHandler = async ({ cookies, params }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/projects/${params.id}`, { + method: 'DELETE' + }); +}; diff --git a/frontend/src/routes/api/projects/[id]/queue-position/+server.ts b/frontend/src/routes/api/projects/[id]/queue-position/+server.ts new file mode 100644 index 0000000..abfa227 --- /dev/null +++ b/frontend/src/routes/api/projects/[id]/queue-position/+server.ts @@ -0,0 +1,11 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const GET: RequestHandler = async ({ cookies, params }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/projects/${params.id}/queue-position`, { + method: 'GET' + }); +}; diff --git a/frontend/src/routes/api/projects/[id]/resubmit/+server.ts b/frontend/src/routes/api/projects/[id]/resubmit/+server.ts new file mode 100644 index 0000000..b29a9c4 --- /dev/null +++ b/frontend/src/routes/api/projects/[id]/resubmit/+server.ts @@ -0,0 +1,14 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const POST: RequestHandler = async ({ cookies, request, params }) => { + const body = await request.json(); + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/projects/${params.id}/resubmit`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); +}; diff --git a/frontend/src/routes/api/projects/[id]/reviews/+server.ts b/frontend/src/routes/api/projects/[id]/reviews/+server.ts new file mode 100644 index 0000000..1d053b2 --- /dev/null +++ b/frontend/src/routes/api/projects/[id]/reviews/+server.ts @@ -0,0 +1,11 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const GET: RequestHandler = async ({ cookies, params }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/projects/${params.id}/reviews`, { + method: 'GET' + }); +}; diff --git a/frontend/src/routes/api/projects/explore/+server.ts b/frontend/src/routes/api/projects/explore/+server.ts new file mode 100644 index 0000000..6562b70 --- /dev/null +++ b/frontend/src/routes/api/projects/explore/+server.ts @@ -0,0 +1,9 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const GET: RequestHandler = async ({ cookies }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/projects/explore`); +}; diff --git a/frontend/src/routes/api/projects/explore/[id]/+server.ts b/frontend/src/routes/api/projects/explore/[id]/+server.ts new file mode 100644 index 0000000..4fd0a87 --- /dev/null +++ b/frontend/src/routes/api/projects/explore/[id]/+server.ts @@ -0,0 +1,9 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const GET: RequestHandler = async ({ cookies, params }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/projects/explore/${params.id}`); +}; diff --git a/frontend/src/routes/api/projects/explore/[id]/comments/+server.ts b/frontend/src/routes/api/projects/explore/[id]/comments/+server.ts new file mode 100644 index 0000000..24f463d --- /dev/null +++ b/frontend/src/routes/api/projects/explore/[id]/comments/+server.ts @@ -0,0 +1,18 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const GET: RequestHandler = async ({ cookies, params }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/projects/explore/${params.id}/comments`); +}; + +export const POST: RequestHandler = async ({ cookies, request, params }) => { + const body = await request.json(); + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/projects/explore/${params.id}/comments`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); +}; diff --git a/frontend/src/routes/api/projects/explore/[id]/comments/[commentId]/+server.ts b/frontend/src/routes/api/projects/explore/[id]/comments/[commentId]/+server.ts new file mode 100644 index 0000000..ecdd66f --- /dev/null +++ b/frontend/src/routes/api/projects/explore/[id]/comments/[commentId]/+server.ts @@ -0,0 +1,13 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const DELETE: RequestHandler = async ({ cookies, params }) => { + return proxyWithRefresh( + cookies, + `${BACKEND_URL}/api/projects/explore/${params.id}/comments/${params.commentId}`, + { method: 'DELETE' } + ); +}; diff --git a/frontend/src/routes/api/projects/hours/+server.ts b/frontend/src/routes/api/projects/hours/+server.ts new file mode 100644 index 0000000..0a38132 --- /dev/null +++ b/frontend/src/routes/api/projects/hours/+server.ts @@ -0,0 +1,9 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const GET: RequestHandler = async ({ cookies }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/projects/hours`); +}; diff --git a/frontend/src/routes/api/shop/+server.ts b/frontend/src/routes/api/shop/+server.ts new file mode 100644 index 0000000..7863d8b --- /dev/null +++ b/frontend/src/routes/api/shop/+server.ts @@ -0,0 +1,9 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const GET: RequestHandler = async ({ cookies }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/shop`); +}; diff --git a/frontend/src/routes/api/shop/fulfillment/+server.ts b/frontend/src/routes/api/shop/fulfillment/+server.ts new file mode 100644 index 0000000..5cc5dd0 --- /dev/null +++ b/frontend/src/routes/api/shop/fulfillment/+server.ts @@ -0,0 +1,9 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const GET: RequestHandler = async ({ cookies }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/shop/fulfillment`); +}; diff --git a/frontend/src/routes/api/shop/fulfillment/read/+server.ts b/frontend/src/routes/api/shop/fulfillment/read/+server.ts new file mode 100644 index 0000000..0455704 --- /dev/null +++ b/frontend/src/routes/api/shop/fulfillment/read/+server.ts @@ -0,0 +1,11 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const POST: RequestHandler = async ({ cookies }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/shop/fulfillment/read`, { + method: 'POST' + }); +}; diff --git a/frontend/src/routes/api/shop/fulfillment/unread/+server.ts b/frontend/src/routes/api/shop/fulfillment/unread/+server.ts new file mode 100644 index 0000000..933f9e1 --- /dev/null +++ b/frontend/src/routes/api/shop/fulfillment/unread/+server.ts @@ -0,0 +1,9 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const GET: RequestHandler = async ({ cookies }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/shop/fulfillment/unread`); +}; diff --git a/frontend/src/routes/api/shop/orders/+server.ts b/frontend/src/routes/api/shop/orders/+server.ts new file mode 100644 index 0000000..2d5dfbe --- /dev/null +++ b/frontend/src/routes/api/shop/orders/+server.ts @@ -0,0 +1,9 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const GET: RequestHandler = async ({ cookies }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/shop/orders`); +}; diff --git a/frontend/src/routes/api/shop/orders/[id]/refund/+server.ts b/frontend/src/routes/api/shop/orders/[id]/refund/+server.ts new file mode 100644 index 0000000..17f442e --- /dev/null +++ b/frontend/src/routes/api/shop/orders/[id]/refund/+server.ts @@ -0,0 +1,11 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const POST: RequestHandler = async ({ cookies, params }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/shop/orders/${params.id}/refund`, { + method: 'POST' + }); +}; diff --git a/frontend/src/routes/api/shop/pipes/+server.ts b/frontend/src/routes/api/shop/pipes/+server.ts new file mode 100644 index 0000000..3fb2af5 --- /dev/null +++ b/frontend/src/routes/api/shop/pipes/+server.ts @@ -0,0 +1,9 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const GET: RequestHandler = async ({ cookies }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/shop/pipes`); +}; diff --git a/frontend/src/routes/api/shop/purchase/+server.ts b/frontend/src/routes/api/shop/purchase/+server.ts new file mode 100644 index 0000000..445d6b2 --- /dev/null +++ b/frontend/src/routes/api/shop/purchase/+server.ts @@ -0,0 +1,14 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const POST: RequestHandler = async ({ cookies, request }) => { + const body = await request.json(); + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/shop/purchase`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); +}; diff --git a/frontend/src/routes/api/shop/suggestions/+server.ts b/frontend/src/routes/api/shop/suggestions/+server.ts new file mode 100644 index 0000000..8aecf5d --- /dev/null +++ b/frontend/src/routes/api/shop/suggestions/+server.ts @@ -0,0 +1,18 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const GET: RequestHandler = async ({ cookies }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/shop/suggestions`); +}; + +export const POST: RequestHandler = async ({ cookies, request }) => { + const body = await request.json(); + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/shop/suggestions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); +}; diff --git a/frontend/src/routes/api/shop/suggestions/[id]/+server.ts b/frontend/src/routes/api/shop/suggestions/[id]/+server.ts new file mode 100644 index 0000000..4db4b58 --- /dev/null +++ b/frontend/src/routes/api/shop/suggestions/[id]/+server.ts @@ -0,0 +1,11 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const DELETE: RequestHandler = async ({ cookies, params }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/shop/suggestions/${params.id}`, { + method: 'DELETE' + }); +}; diff --git a/frontend/src/routes/api/shop/suggestions/[id]/vote/+server.ts b/frontend/src/routes/api/shop/suggestions/[id]/vote/+server.ts new file mode 100644 index 0000000..aa4bb71 --- /dev/null +++ b/frontend/src/routes/api/shop/suggestions/[id]/vote/+server.ts @@ -0,0 +1,11 @@ +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { RequestHandler } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const POST: RequestHandler = async ({ cookies, params }) => { + return proxyWithRefresh(cookies, `${BACKEND_URL}/api/shop/suggestions/${params.id}/vote`, { + method: 'POST' + }); +}; diff --git a/frontend/src/routes/auth/hackatime/callback/+page.server.ts b/frontend/src/routes/auth/hackatime/callback/+page.server.ts new file mode 100644 index 0000000..0943bc7 --- /dev/null +++ b/frontend/src/routes/auth/hackatime/callback/+page.server.ts @@ -0,0 +1,63 @@ +import { redirect } from '@sveltejs/kit'; +import { env } from '$env/dynamic/private'; +import type { PageServerLoad } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const load: PageServerLoad = async ({ url, cookies }) => { + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + const storedState = cookies.get('hackatime_state'); + const token = cookies.get('auth_token'); + + // Clean up one-time cookie + cookies.delete('hackatime_state', { path: '/' }); + + if (!token) { + redirect(302, '/'); + } + + if (!code || !state) { + const oauthError = + url.searchParams.get('error_description') ?? url.searchParams.get('error'); + if (oauthError) { + console.error(`Hackatime OAuth error: ${oauthError}`); + } + return { error: 'Hackatime connection could not be completed. Please try again.' }; + } + + // Fail early if the state cookie is missing (expired or never set) + if (!storedState) { + return { error: 'Session expired. Please try connecting Hackatime again.' }; + } + + // Forward to backend with both the OAuth params and the user's auth token + const res = await fetch(`${BACKEND_URL}/api/hackatime/callback`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ code, state, storedState }) + }); + + if (!res.ok) { + return { error: 'Hackatime connection failed' }; + } + + const { redirectTo } = await res.json(); + + // Banned users get redirected to fraud page + if (redirectTo === 'https://fraud.hackclub.com/') { + cookies.delete('auth_token', { path: '/' }); + cookies.delete('refresh_token', { path: '/' }); + redirect(302, 'https://fraud.hackclub.com/'); + } + + // Defense-in-depth: only follow relative redirects from the backend + if (typeof redirectTo !== 'string' || !redirectTo.startsWith('/') || redirectTo.startsWith('//')) { + redirect(302, '/tutorial'); + } + + redirect(302, redirectTo); +}; diff --git a/frontend/src/routes/auth/hackatime/callback/+page.svelte b/frontend/src/routes/auth/hackatime/callback/+page.svelte new file mode 100644 index 0000000..b2efa7f --- /dev/null +++ b/frontend/src/routes/auth/hackatime/callback/+page.svelte @@ -0,0 +1,31 @@ + + +{#if data.error} +
+

{data.error}

+ Back to tutorial +
+{:else} +

Connecting Hackatime…

+{/if} + + diff --git a/frontend/src/routes/guide/+page.server.ts b/frontend/src/routes/guide/+page.server.ts new file mode 100644 index 0000000..f4649b8 --- /dev/null +++ b/frontend/src/routes/guide/+page.server.ts @@ -0,0 +1,19 @@ +import { env } from '$env/dynamic/private'; +import type { PageServerLoad } from './$types'; + +export const prerender = false; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +type ReviewStat = { projectType: string; avgSeconds: number; sampleCount: number }; + +export const load: PageServerLoad = async () => { + let stats: ReviewStat[] = []; + try { + const res = await fetch(`${BACKEND_URL}/api/projects/review-stats`); + if (res.ok) stats = (await res.json()) as ReviewStat[]; + } catch { + // Backend unreachable: render the guide without stats rather than fail the page. + } + return { reviewStats: stats }; +}; diff --git a/frontend/src/routes/guide/+page.svelte b/frontend/src/routes/guide/+page.svelte new file mode 100644 index 0000000..349b369 --- /dev/null +++ b/frontend/src/routes/guide/+page.svelte @@ -0,0 +1,1021 @@ + + + + + Shipping Guide — Beest + + + + + + +
+ + +
+

Shipping Guide

+

Hey all! As of 05/05/2026, more than half of projects on #beest are being sent back for changes needed. I wrote this guide to document requirements for each project type, so that rejections shouldn't come as a surprise!

+ + {#each guides as guide (guide.type)} +
+

+ + {guide.label} + {#if statByType[guide.type] && statByType[guide.type].sampleCount > 0} + + ~{formatDuration(statByType[guide.type].avgSeconds)} avg review + + {/if} +

+ +

{guide.intro}

+ + {#if guide.warning} +
+ + {guide.warning} +
+ {/if} + + {#if guide.pills && guide.pills.length > 0} + {#if guide.pillsTitle}

{guide.pillsTitle}

{/if} +
    + {#each guide.pills as p} +
  • + + + {p.label} + {p.note} + +
  • + {/each} +
+ {/if} + +

Steps

+
    + {#each guide.steps as step, idx} +
  1. + {idx + 1} + {@html step} +
  2. + {/each} +
+ + {#if guide.commonRejections && guide.commonRejections.length > 0} +

Common rejection reasons

+
    + {#each guide.commonRejections as r} +
  • + ×{r.count} + {r.reason} +
  • + {/each} +
+ {/if} + + {#if guide.tips && guide.tips.length > 0} +
+
    + {#each guide.tips as tip}
  • {@html tip}
  • {/each} +
+
+ {/if} +
+ {/each} + +
+
+ + diff --git a/frontend/src/routes/home/+page.server.ts b/frontend/src/routes/home/+page.server.ts new file mode 100644 index 0000000..ac0d9cd --- /dev/null +++ b/frontend/src/routes/home/+page.server.ts @@ -0,0 +1,40 @@ +import { redirect } from '@sveltejs/kit'; +import { getAuthenticatedUser } from '$lib/server/auth'; +import { env } from '$env/dynamic/private'; +import type { PageServerLoad } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const load: PageServerLoad = async ({ cookies }) => { + const user = await getAuthenticatedUser(cookies); + if (!user) redirect(302, '/'); + + // Check if user has elevated permissions (don't block page load on failure) + let role: string | null = null; + let needsIntent = false; + const token = cookies.get('auth_token'); + if (token) { + try { + const res = await fetch(`${BACKEND_URL}/api/auth/scope?scope=reviewer`, { + headers: { Authorization: `Bearer ${token}` } + }); + if (res.ok) { + const data = await res.json(); + role = data.perms ?? null; + } + } catch { /* non-critical */ } + + // One-time "hackathon or shop?" prompt — shows until the user answers. + try { + const res = await fetch(`${BACKEND_URL}/api/auth/intent`, { + headers: { Authorization: `Bearer ${token}` } + }); + if (res.ok) { + const data = await res.json(); + needsIntent = data.needsPrompt === true; + } + } catch { /* non-critical — just don't show the prompt */ } + } + + return { user, role, needsIntent }; +}; diff --git a/frontend/src/routes/home/+page.svelte b/frontend/src/routes/home/+page.svelte new file mode 100644 index 0000000..d30f9cd --- /dev/null +++ b/frontend/src/routes/home/+page.svelte @@ -0,0 +1,7825 @@ + + + + + +
+ + + + + +
+ + {#if editingProject?.status === 'approved'} + +
+
+ + +
+
+ {#if editingProject.screenshot1Url} + Screenshot + {/if} +
+

{editingProject.name}

+ approved +

{editingProject.description}

+
+ {editingProject.projectType} + {#if editingProject.codeUrl}Code{/if} + {#if editingProject.demoUrl}Demo{/if} + {#if editingProject.readmeUrl}README{/if} +
+
+
+
+ + {#if editingProjectReviews.length > 0} +
+

Review history

+ {#each editingProjectReviews as review} +
+
+ {review.status === 'changes_needed' ? 'Changes Needed' : 'Approved'} + {#if review.reviewerName} + by {review.reviewerName} + {/if} + {new Date(review.createdAt).toLocaleDateString()} +
+ {#if review.feedback} +

{review.feedback}

+ {:else} +

No feedback provided.

+ {/if} +
+ {/each} +
+ {/if} + +
+

Resubmit for Review

+

Ship an update to this approved project to earn more Pipes.

+ +
+ + +
+ Be specific about what changed + = 500}>{resubmitChangeDesc.length}/500 +
+ + + + + +
+ Visible only to reviewers + = 1000}>{resubmitReviewerNote.length}/1000 +
+ + {#if formError} +

{formError}

+ {/if} + + +
+
+
+
+ {:else if creatingProject || editingProject} + +
keystrokes++}> +
+ +
+ + {#if editingProject && editingProjectReviews.length > 0} +
+

+ {#if editingProject.status === 'changes_needed'}Reviewer requested changes{:else}Review history{/if} +

+ {#each editingProjectReviews as review} +
+
+ {review.status === 'changes_needed' ? 'Changes Needed' : 'Approved'} + {#if review.reviewerName} + by {review.reviewerName} + {/if} + {new Date(review.createdAt).toLocaleDateString()} +
+ {#if review.feedback} +

{review.feedback}

+ {:else} +

No feedback provided.

+ {/if} +
+ {/each} +
+ {/if} + +
+
+ + +
+ Give your project a name + = 50}>{projectName.length}/50 +
+
+ +
+ + +
+ Describe your idea + = 300}>{projectDesc.length}/300 +
+
+ +
+
+ + + Link to your source code (GitHub, GitLab, etc) +
+
+ + + Link to your project's README file +
+
+ +
+ + + Link to a live demo or playable version +
+ +
+
+ +
+
+ +
+ + +
+
+
+ {#if screenshotPreviews[activeScreenshot]} + Preview {activeScreenshot + 1} + + {/if} +
+
+ + Upload up to 2 screenshots +
+ +
+ + + + + + +
+ Describe how AI was used in this project + = 200}>{aiUseDescription.length}/200 +
+
+
+ +
+
+ + +
+
+ +
+
+ + +
{ hackatimeOpen = !hackatimeOpen; }}> + {#if hackatimeProject.length === 0} + Select projects + {:else} + {hackatimeProject.join(', ')} + {/if} +
+ {#if hackatimeOpen} +
+ {#if hackatimeLoading} + Loading... + {:else if hackatimeProjects.length === 0} + No projects found, redo tutorial? + {:else} + {#each hackatimeProjects as proj} + + {/each} + {/if} +
+ {/if} +
+
{ fetchHackatimeProjects(); setTimeout(fetchHackatimeProjects, 30000); }}> + +
+
+
+
+ +
+ + +
+ +
+ + {#if formError} +

{formError}

+ {/if} + +
+ {#if otherHcProgram} +
+ + +
+ {/if} + {#if editingProject?.status === 'unreviewed'} +
+

This project is currently in review. You can still work on it and track hours, but you can't resubmit until it's been reviewed.

+ {#if editingProjectQueue} +

+ Queue position: {editingProjectQueue.position} of {editingProjectQueue.total} +

+ {/if} + +
+ {:else if editingProject?.status === 'fraud_pending'} +
+

This project has been reviewed and is awaiting fraud checks. You'll be notified once the fraud team finishes their review.

+
+ {/if} +
+ {#if editingProject && editingProject.status !== 'approved'} + + {/if} + + {#if editingProject && editingProject.status !== 'unreviewed'} +
+ + {#if !canSubmitForReview} + Fill out all sections before submitting + {/if} +
+ {/if} +
+
+ + + + +
+ {/if} + + {#if showShippingPrompt && shippingCheck} +
+
+ +

Complete Your Profile

+

Before submitting for review, we need a few things from your Hack Club Auth profile — we use these to verify you and to ship prizes when your project is approved:

+
+ {#if !shippingCheck.identityVerified} +
+ + Identity not verified +
+ {:else} +
+ + Identity verified +
+ {/if} + {#if !shippingCheck.hasAddress} +
+ + Shipping address not set +
+ {:else} +
+ + Shipping address +
+ {/if} + {#if !shippingCheck.hasBirthdate} +
+ + Birthdate not set +
+ {:else} +
+ + Birthdate +
+ {/if} +
+ {#if !shippingCheck.identityVerified} + + Verify your identity + + {#if !identityPollExhausted} +

Checking automatically — verification can take a few seconds to reflect after Hack Club approves your document.

+ {:else} + +

We stopped checking automatically. Tap above once HQ approves your document.

+ {/if} + {:else if shippingCheck.eligible} +

Identity verified! Click Submit again to ship.

+ {/if} + {#if !shippingCheck.hasAddress || !shippingCheck.hasBirthdate} + + Update address & birthdate + +

Address and birthdate changes require a log out / log back in to refresh.

+ {/if} +
+
+ {/if} + + {#if reviewProject} +
+
+ +

Submit "{reviewProject.name}" for Review

+
+ + + + + +
+ +
+ + +
+ Visible only to reviewers + = 1000}>{reviewerNote.length}/1000 +
+
+ + {#if formError} +

{formError}

+ {/if} + +
+
+ {/if} + + {#if reviewSubmitted} +
+
+

"{reviewSubmittedName}" Submitted!

+

Review means a human is looking over your project and checking that the code is functional, not AI generated and that the demo works. It could take around a week (hopefully less) for us to get around to your project, at which point we will offer feedback or approve your time spent. In rare cases where we believe you have unintentionally exaggerated your hours, we may approve a percentage of your hours.

+ +
+
+ {/if} + + {#if !creatingProject && !editingProject && !reviewProject && activeSection === 'projects'} +
+
+
+
+

My Projects

+

Track your progress and hours.

+
+
+ Approved + Unreviewed + Changes Needed + Unshipped +
+
+ +
+
+ {displayHours}h + {(hoursByStatus['approved'] ?? 0) >= GOAL_HOURS ? `${GOAL_HOURS}h approved` : `${GOAL_HOURS}h to qualify`} +
+
+ {#each ['approved', 'unreviewed', 'changes_needed', 'unshipped'] as status} + {@const pct = Math.min(((displayByStatus[status] ?? 0) / GOAL_HOURS) * 100, 100)} + {@const label = status === 'changes_needed' ? 'Changes Needed' : status.charAt(0).toUpperCase() + status.slice(1)} + {#if pct > 0} +
+ {/if} + {/each} +
+
+ 0 + 10 + 20 + 30 + 40 +
+
+ +
0} style:--cols={projectCols}> + {#if projects.length === 0} +

No projects yet. Start building to earn hours!

+ + {:else} + {#each projects as project} + {@const isMobile = project.projectType === 'android' || project.projectType === 'ios'} +
openEditProject(project)} onkeydown={(e) => { if (e.key === 'Enter') openEditProject(project); }}> + {#if project.screenshot1Url} + {project.name} screenshot + {:else if catImages[project.id]} +
+ Placeholder cat + placeholder cat - upload your project screenshot instead +
+ {/if} +
+
+

{project.name}

+ {project.projectType} + {project.status === 'changes_needed' ? 'Changes Needed' : project.status === 'fraud_pending' ? 'In Review' : project.status} +
+

{project.description}

+ +
+
+ {/each} + {/if} +
+ {#if projects.length > 0} +
+ + Shipping Guide +
+ {/if} + +
+
+

Action Log

+
+ {#if auditLog.length === 0} +

No activity yet.

+ {:else} + {#each auditLog as entry} +
+ +
+

{entry.label}

+ {timeAgo(entry.createdAt)} +
+
+ {/each} + {/if} +
+
+ +
+

News

+
+ {#if newsItems.length === 0} +

No news yet.

+ {:else} + {#each newsItems as item} +
+ {new Date(item.displayDate + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} +

{item.text}

+
+ {/each} + {/if} +
+
+
+
+
+ {/if} + + {#if activeSection === 'shop'} +
+
+
+
+
+
+

Earn Prizes

+

Build projects, earn hours, unlock rewards.
Hours spent in the shop detract from qualifying hours!

+
+
+ +
+ Pipes +
+ Pipes + {userPipes} +
+
+
+
+
+
+
+ {#each {length: 8} as _} + 1 approved hour = 1 pipe, spend pipes on prizes   |    + {/each} +
+
+ {#if shopLoading} +
+ {#each Array(6) as _} +
+
+
+
+
+ {/each} +
+ {:else if shopItems.length === 0} +

No items in the shop yet.

+ {:else} + {#if shopItems.some(i => i.isFeatured)} + + {/if} + {#if shopItems.some(i => !i.isFeatured)} + {#if shopItems.some(i => i.isFeatured)} +

All Items

+ {/if} +
+ {#each shopItems.filter(i => !i.isFeatured) as item} + + {/each} +
+ {/if} + {/if} + +
+

My Orders

+ {#if userOrdersLoading && userOrders.length === 0} +

Loading…

+ {:else if userOrders.length === 0} +

You haven't placed any orders yet.

+ {:else} + {#if refundError} +

{refundError}

+ {/if} +
    + {#each userOrders as order} +
  • +
    + {order.quantity}× {order.itemName} + + {order.pipesSpent} Pipes · {new Date(order.createdAt).toLocaleDateString()} + +
    +
    + {#if order.status === 'pending'} + Pending + + {:else} + Fulfilled + {/if} +
    +
  • + {/each} +
+ {/if} +
+
+
+
+ + + {#if selectedShopItem} + +
{ if (e.key === 'Escape') closeShopItem(); }}> + +
e.stopPropagation()}> + +
+
+ {selectedShopItem.name} +
+
+

{selectedShopItem.name}

+

{selectedShopItem.description}

+ {#if selectedShopItem.detailedDescription} +

{selectedShopItem.detailedDescription}

+ {/if} + +
+ {selectedShopItem.priceHours} Pipes + {#if selectedShopItem.stock !== null} + {selectedShopItem.stock} in stock + {:else} + Unlimited + {/if} +
+ + {#if selectedShopItem.estimatedShip} +
+ + {selectedShopItem.estimatedShip} +
+ {/if} + +
+ Quantity +
+ { + setShopQuantity(e.currentTarget.value); + e.currentTarget.value = shopQuantityText; + }} + onblur={commitShopQuantity} + aria-label="Quantity" + /> +
+
+ +
+ Total: + {selectedShopItem.priceHours * shopQuantity} Pipes +
+ + {#if purchaseSuccess} +
{purchaseSuccess}
+ {:else if userPipes >= selectedShopItem.priceHours * shopQuantity} + + {#if purchaseError} +

{purchaseError}

+ {/if} + {:else} +
+

You need {(selectedShopItem.priceHours * shopQuantity) - userPipes} more Pipes to redeem this.

+

Keep building!

+
+ {/if} +
+
+
+
+ {/if} + + + {#if suggestionsOpen} + +
{ if (e.key === 'Escape') closeSuggestions(); }}> + +
e.stopPropagation()}> + +

Shop Suggestions

+

What should we add to the shop? Upvote ideas you like.

+ +
+ + + {#if suggestionError} +

{suggestionError}

+ {/if} +
+ +
+ {#if suggestionsLoading} +

Loading...

+ {:else if suggestions.length === 0} +

No suggestions yet — be the first!

+ {:else} + {#each suggestions as s (s.id)} +
+ +
+

{s.text}

+

+ by {s.authorName} + {#if s.isMine} + + {/if} +

+
+
+ {/each} + {/if} +
+
+
+ {/if} + {/if} + + + {#if showIntent} + + + {/if} + + {#if activeSection === 'explore'} +
+
+

Explore

+

Discover what others are building, get inspiration!

+ {#if exploreLoading} +
+
+ {#each Array(6) as _} +
+
+
+
+
+ {/each} +
+
+ {:else if exploreProjects.length === 0} +

Awaiting the first projects...

+ {:else} +
+ {#each exploreProjects as ep} + +
openProjectDetail(ep.id)} role="button" tabindex="0" onkeydown={(e) => { if (e.key === 'Enter') openProjectDetail(ep.id); }}> + {#if ep.screenshot1Url || ep.screenshot2Url} +
+ {ep.name} + {#if ep.screenshot1Url && ep.screenshot2Url} + + +
+ + +
+ {/if} +
+ {/if} +
+
+

{ep.name}

+ {ep.projectType} +
+

{ep.description}

+
+ by {ep.builderName} + {#if ep.hours > 0} + {ep.hours.toFixed(1)}h + {/if} +
+
+
+ {/each} +
+ {/if} +
+
+ {/if} + + {#if activeSection === 'leaderboard'} +
+
+
+
+

Leaderboard

+

Top builders by approved hours.

+
+
+ Builders + {leaderboardLoading ? '—' : totalBuilders} +
+
+
+
+ # + Builder + Hours +
+ {#if leaderboardLoading} + {#each Array(10) as _, i} +
+ {i + 1} + + +
+ {/each} + {:else if leaderboard.length > 0} + {#each leaderboard as entry, i} +
+ {i + 1} + {entry.name} + {Math.round(entry.hours * 10) / 10}h +
+ {/each} + {#if leaderboard.length < leaderboardTotal} + + {/if} + {/if} +
+
+
+ {/if} + + {#if activeSection === 'faq'} +
+
+ + + + + + +

Frequently Asked Questions

+

I'm sure you have lots of questions! Below is the most common ones I see, but if you need more help please email beest@hackclub.com or use the dedicated slack channel #beest-help

+ +
+ {#each faqItems as faq, i (faq.q)} + + {/each} +
+
+
+ {/if} + + {#if activeSection === 'devlogs'} +
+
+

Devlogs

+

Devlogs are mandatory for hardware projects and optional for software.

+ + {#if !devlogFormVisible} + + {/if} + + {#if devlogFormVisible} +
+
+
+ + +
+ +
+ + +
+ = DEVLOG_TITLE_MAX}>{devlogTitle.length}/{DEVLOG_TITLE_MAX} +
+
+ +
+ + +
+ = DEVLOG_TEXT_MAX}>{devlogText.length}/{DEVLOG_TEXT_MAX} +
+
+ + {#if devlogError} +

{devlogError}

+ {/if} + + +
+ + +
+ {#if devlogs.length > 0} + + {/if} + {/if} + +
+ {#if devlogsLoading && devlogs.length === 0} +

Loading…

+ {:else if devlogs.length > 0} + {#each devlogs as dl (dl.id)} +
+
+ {devlogProjectName(dl.projectId) ?? 'Project removed'} + {new Date(dl.createdAt).toLocaleString()} + +
+

{dl.title}

+

{dl.text}

+ {#if dl.imageUrls && dl.imageUrls.length > 0} +
+ {#each dl.imageUrls as url} + + {/each} +
+ {/if} +
+ {/each} + {/if} +
+
+
+ {/if} + + {#if activeSection === 'me'} +
+
+

Me

+ +
+ + +
+ + + + +
+
+
+
+ {/if} + +
+ + + {#if detailProject} + + {/if} +
+ + + +{#if devlogLightbox} + + + +{/if} + + diff --git a/frontend/src/routes/oauth/callback/+page.server.ts b/frontend/src/routes/oauth/callback/+page.server.ts new file mode 100644 index 0000000..44fdf82 --- /dev/null +++ b/frontend/src/routes/oauth/callback/+page.server.ts @@ -0,0 +1,73 @@ +import { redirect } from '@sveltejs/kit'; +import { env } from '$env/dynamic/private'; +import type { PageServerLoad } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const load: PageServerLoad = async ({ url, cookies }) => { + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + const storedState = cookies.get('oauth_state'); + + // Clean up one-time cookie + cookies.delete('oauth_state', { path: '/' }); + + if (!code || !state) { + // Log the provider's error server-side only + const oauthError = url.searchParams.get('error_description') ?? url.searchParams.get('error'); + if (oauthError) { + console.error(`OAuth error from provider: ${oauthError}`); + } + return { error: 'Authentication could not be completed. Please try again.' }; + } + + // Read attribution cookie (set client-side on landing) and clear it + let attribution: unknown = undefined; + const attributionRaw = cookies.get('attribution'); + if (attributionRaw) { + try { + attribution = JSON.parse(attributionRaw); + } catch { + // malformed — ignore + } + cookies.delete('attribution', { path: '/' }); + } + + // Forward everything to the backend + const res = await fetch(`${BACKEND_URL}/api/auth/handle-callback`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code, state, storedState, attribution }) + }); + + if (!res.ok) { + return { error: 'Authentication failed' }; + } + + const { token, refreshToken, redirectTo } = await res.json(); + + const cookieOpts = { + path: '/', + httpOnly: true, + sameSite: 'lax' as const, + secure: env.NODE_ENV === 'production' + }; + + // Banned users get redirected without receiving tokens + if (redirectTo === 'https://fraud.hackclub.com/') { + redirect(302, 'https://fraud.hackclub.com/'); + } + + // Store JWT (1h) and refresh token (90d) in httpOnly cookies + cookies.set('auth_token', token, { ...cookieOpts, maxAge: 3600 }); + cookies.set('refresh_token', refreshToken, { + ...cookieOpts, + maxAge: 90 * 24 * 60 * 60 + }); + + // Defense-in-depth: only follow relative redirects + if (typeof redirectTo !== 'string' || !redirectTo.startsWith('/') || redirectTo.startsWith('//')) { + redirect(302, '/home'); + } + redirect(302, redirectTo); +}; diff --git a/frontend/src/routes/oauth/callback/+page.svelte b/frontend/src/routes/oauth/callback/+page.svelte new file mode 100644 index 0000000..6b6969f --- /dev/null +++ b/frontend/src/routes/oauth/callback/+page.svelte @@ -0,0 +1,34 @@ + + +
+ {#if data.error} +

Login failed: {data.error}

+ Back to home + {:else} +

Redirecting...

+ {/if} +
+ + diff --git a/frontend/src/routes/oauth/hcb/callback/+page.server.ts b/frontend/src/routes/oauth/hcb/callback/+page.server.ts new file mode 100644 index 0000000..173f8a2 --- /dev/null +++ b/frontend/src/routes/oauth/hcb/callback/+page.server.ts @@ -0,0 +1,35 @@ +import { redirect } from '@sveltejs/kit'; +import { env } from '$env/dynamic/private'; +import { proxyWithRefresh } from '$lib/server/auth'; +import type { PageServerLoad } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const load: PageServerLoad = async ({ url, cookies }) => { + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + const storedState = cookies.get('hcb_oauth_state'); + + // One-time cookie — always clear it. + cookies.delete('hcb_oauth_state', { path: '/' }); + + if (!code || !state || !storedState) { + const oauthError = + url.searchParams.get('error_description') ?? url.searchParams.get('error'); + if (oauthError) console.error(`HCB OAuth error from provider: ${oauthError}`); + return { error: 'HCB connection could not be completed. Please try again.' }; + } + + // handle-callback is Super Admin guarded — proxyWithRefresh forwards the admin's JWT. + const res = await proxyWithRefresh(cookies, `${BACKEND_URL}/api/admin/hcb/handle-callback`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code, state, storedState }) + }); + + if (!res.ok) { + return { error: 'HCB connection failed. Make sure you are signed in as a Super Admin.' }; + } + + redirect(302, '/admin?hcb=connected'); +}; diff --git a/frontend/src/routes/oauth/hcb/callback/+page.svelte b/frontend/src/routes/oauth/hcb/callback/+page.svelte new file mode 100644 index 0000000..01d0b31 --- /dev/null +++ b/frontend/src/routes/oauth/hcb/callback/+page.svelte @@ -0,0 +1,34 @@ + + +
+ {#if data.error} +

HCB connection failed: {data.error}

+ Back to admin + {:else} +

Connecting to HCB...

+ {/if} +
+ + diff --git a/frontend/src/routes/tutorial/+page.server.ts b/frontend/src/routes/tutorial/+page.server.ts new file mode 100644 index 0000000..c5ce843 --- /dev/null +++ b/frontend/src/routes/tutorial/+page.server.ts @@ -0,0 +1,25 @@ +import { redirect } from '@sveltejs/kit'; +import { env } from '$env/dynamic/private'; +import { getAuthenticatedUser } from '$lib/server/auth'; +import type { PageServerLoad } from './$types'; + +const BACKEND_URL = env.BACKEND_URL ?? 'http://localhost:3001'; + +export const load: PageServerLoad = async ({ url, cookies }) => { + const user = await getAuthenticatedUser(cookies); + if (!user) redirect(302, '/'); + + const token = cookies.get('auth_token')!; + + const onboardingRes = await fetch(`${BACKEND_URL}/api/onboarding/status`, { + headers: { Authorization: `Bearer ${token}` } + }); + + const onboarding = onboardingRes.ok + ? await onboardingRes.json() + : { hackatime: false, slack: false, project: false }; + + const stage = url.searchParams.get('stage'); + + return { user, onboarding, stage: stage ? parseInt(stage, 10) : null }; +}; diff --git a/frontend/src/routes/tutorial/+page.svelte b/frontend/src/routes/tutorial/+page.svelte new file mode 100644 index 0000000..e373be8 --- /dev/null +++ b/frontend/src/routes/tutorial/+page.svelte @@ -0,0 +1,1053 @@ + + + + +
+
+
{ if (e.key === 'Enter' || e.key === ' ') skipIntro(); }}> + + + + + + + + + + + + + + + + + + {#if showStart} + + {/if} + + + + + +
+
+ +{#if transitioned} +
+
+
+ {#each clouds as cloud} + + {/each} + + +
+
+

You're all set! If you get stuck you can replay this tutorial, read the FAQ or ask in #beest-help.

+ GO! +
+
+ + +
+

Create a Project

+

Tell us your idea! It doesn't have to be related to the beest, make an automation you've always wanted or a game for you and your friends. Make anything! (Just not AI slop or college projects, we only want to reward creativity and real learning.)

+ {#if projectCreated && !showProjectForm} + + {:else if showProjectForm} +
+ + + + + + + + + + {#if projectError} +

{projectError}

+ {/if} + +
+ + +
+
+ {:else} + + {/if} +
+ + +
+

Connect Hackatime

+

We want to reward you for time spent building, so we made Hackatime! Its like a smart stopwatch that automatically tracks how long you code for, and it works in all your existing code editors. To be rewarded for your work youll need to set up an account on hackatime.hackclub.com and then hit connect to link it to Beest!

+ {#if data.onboarding.hackatime} + + {:else} +
+ +
+ + {/if} +
+ + + +
+

Join Slack

+ {#if slackStatus === 'full_member'} +

It looks like you are already on the Hack Club Slack! We're so glad to have you :)

+ + {:else if slackStatus === 'guest'} +

Hey! It looks like you are new to our community! Check your email for a message from Slack, then follow the instructions in #welcome-to-hack-club. Slack is where everyone is talking - theres 100 THOUSAND technical teens waiting to hear from you.

+ Join! + + + + {:else} +

Join the Hack Club Slack to meet other builders, get help, and share your progress.

+ Join! + + + + {/if} +
+
+ + + +
+
+{#if locked} + +{/if} +{/if} + + diff --git a/frontend/static/favicon.ico b/frontend/static/favicon.ico new file mode 100644 index 0000000..c97fbbc Binary files /dev/null and b/frontend/static/favicon.ico differ diff --git a/frontend/static/favicon.svg b/frontend/static/favicon.svg new file mode 100644 index 0000000..8310a27 --- /dev/null +++ b/frontend/static/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/static/favicon.webp b/frontend/static/favicon.webp new file mode 100644 index 0000000..dd596f9 Binary files /dev/null and b/frontend/static/favicon.webp differ diff --git a/beest/static/fonts/Stone Breaker.otf b/frontend/static/fonts/Stone Breaker.otf similarity index 100% rename from beest/static/fonts/Stone Breaker.otf rename to frontend/static/fonts/Stone Breaker.otf diff --git a/beest/static/fonts/Stone Breaker.woff2 b/frontend/static/fonts/Stone Breaker.woff2 similarity index 100% rename from beest/static/fonts/Stone Breaker.woff2 rename to frontend/static/fonts/Stone Breaker.woff2 diff --git a/beest/static/fonts/SunnyMood.ttf b/frontend/static/fonts/SunnyMood.ttf similarity index 100% rename from beest/static/fonts/SunnyMood.ttf rename to frontend/static/fonts/SunnyMood.ttf diff --git a/beest/static/fonts/SunnyMood.woff2 b/frontend/static/fonts/SunnyMood.woff2 similarity index 100% rename from beest/static/fonts/SunnyMood.woff2 rename to frontend/static/fonts/SunnyMood.woff2 diff --git a/frontend/static/images/Beach.webp b/frontend/static/images/Beach.webp new file mode 100644 index 0000000..fff05d9 Binary files /dev/null and b/frontend/static/images/Beach.webp differ diff --git a/frontend/static/images/Water swooosh.webp b/frontend/static/images/Water swooosh.webp new file mode 100644 index 0000000..57b874c Binary files /dev/null and b/frontend/static/images/Water swooosh.webp differ diff --git a/frontend/static/images/beest-cropped/1.webp b/frontend/static/images/beest-cropped/1.webp new file mode 100644 index 0000000..a10fb7d Binary files /dev/null and b/frontend/static/images/beest-cropped/1.webp differ diff --git a/frontend/static/images/beest-cropped/10.webp b/frontend/static/images/beest-cropped/10.webp new file mode 100644 index 0000000..82f5e0e Binary files /dev/null and b/frontend/static/images/beest-cropped/10.webp differ diff --git a/frontend/static/images/beest-cropped/11.webp b/frontend/static/images/beest-cropped/11.webp new file mode 100644 index 0000000..b8e53a2 Binary files /dev/null and b/frontend/static/images/beest-cropped/11.webp differ diff --git a/frontend/static/images/beest-cropped/2.webp b/frontend/static/images/beest-cropped/2.webp new file mode 100644 index 0000000..cf55a3c Binary files /dev/null and b/frontend/static/images/beest-cropped/2.webp differ diff --git a/frontend/static/images/beest-cropped/3.webp b/frontend/static/images/beest-cropped/3.webp new file mode 100644 index 0000000..9df417b Binary files /dev/null and b/frontend/static/images/beest-cropped/3.webp differ diff --git a/frontend/static/images/beest-cropped/4.webp b/frontend/static/images/beest-cropped/4.webp new file mode 100644 index 0000000..1e9cea0 Binary files /dev/null and b/frontend/static/images/beest-cropped/4.webp differ diff --git a/frontend/static/images/beest-cropped/5.webp b/frontend/static/images/beest-cropped/5.webp new file mode 100644 index 0000000..f7ed526 Binary files /dev/null and b/frontend/static/images/beest-cropped/5.webp differ diff --git a/frontend/static/images/beest-cropped/6.webp b/frontend/static/images/beest-cropped/6.webp new file mode 100644 index 0000000..37f4114 Binary files /dev/null and b/frontend/static/images/beest-cropped/6.webp differ diff --git a/frontend/static/images/beest-cropped/7.webp b/frontend/static/images/beest-cropped/7.webp new file mode 100644 index 0000000..7fa1a18 Binary files /dev/null and b/frontend/static/images/beest-cropped/7.webp differ diff --git a/frontend/static/images/beest-cropped/8.webp b/frontend/static/images/beest-cropped/8.webp new file mode 100644 index 0000000..7584322 Binary files /dev/null and b/frontend/static/images/beest-cropped/8.webp differ diff --git a/frontend/static/images/beest-cropped/9.webp b/frontend/static/images/beest-cropped/9.webp new file mode 100644 index 0000000..3c90e4b Binary files /dev/null and b/frontend/static/images/beest-cropped/9.webp differ diff --git a/beest/static/images/beest.gif b/frontend/static/images/beest.gif similarity index 100% rename from beest/static/images/beest.gif rename to frontend/static/images/beest.gif diff --git a/frontend/static/images/beest.webp b/frontend/static/images/beest.webp new file mode 100644 index 0000000..56801e4 Binary files /dev/null and b/frontend/static/images/beest.webp differ diff --git a/frontend/static/images/beest2.webp b/frontend/static/images/beest2.webp new file mode 100644 index 0000000..eb221a4 Binary files /dev/null and b/frontend/static/images/beest2.webp differ diff --git a/frontend/static/images/bg-tile.webp b/frontend/static/images/bg-tile.webp new file mode 100644 index 0000000..c7310eb Binary files /dev/null and b/frontend/static/images/bg-tile.webp differ diff --git a/frontend/static/images/cloud-left.webp b/frontend/static/images/cloud-left.webp new file mode 100644 index 0000000..dbbf3df Binary files /dev/null and b/frontend/static/images/cloud-left.webp differ diff --git a/frontend/static/images/cloud-right.webp b/frontend/static/images/cloud-right.webp new file mode 100644 index 0000000..5e99869 Binary files /dev/null and b/frontend/static/images/cloud-right.webp differ diff --git a/frontend/static/images/cloud.webp b/frontend/static/images/cloud.webp new file mode 100644 index 0000000..01626c2 Binary files /dev/null and b/frontend/static/images/cloud.webp differ diff --git a/beest/static/images/frames/75 teens at Campfire Flagship.webp b/frontend/static/images/frames/75 teens at Campfire Flagship.webp similarity index 100% rename from beest/static/images/frames/75 teens at Campfire Flagship.webp rename to frontend/static/images/frames/75 teens at Campfire Flagship.webp diff --git a/beest/static/images/frames/Hackathon on an island.webp b/frontend/static/images/frames/Hackathon on an island.webp similarity index 100% rename from beest/static/images/frames/Hackathon on an island.webp rename to frontend/static/images/frames/Hackathon on an island.webp diff --git a/beest/static/images/frames/Teen hackers at Assemble.webp b/frontend/static/images/frames/Teen hackers at Assemble.webp similarity index 100% rename from beest/static/images/frames/Teen hackers at Assemble.webp rename to frontend/static/images/frames/Teen hackers at Assemble.webp diff --git a/beest/static/images/frames/Teens at a local game Jam.webp b/frontend/static/images/frames/Teens at a local game Jam.webp similarity index 100% rename from beest/static/images/frames/Teens at a local game Jam.webp rename to frontend/static/images/frames/Teens at a local game Jam.webp diff --git a/beest/static/images/frames/Winners of Parthenon Hackathon.webp b/frontend/static/images/frames/Winners of Parthenon Hackathon.webp similarity index 100% rename from beest/static/images/frames/Winners of Parthenon Hackathon.webp rename to frontend/static/images/frames/Winners of Parthenon Hackathon.webp diff --git a/beest/static/images/frames/hackers debugging together.webp b/frontend/static/images/frames/hackers debugging together.webp similarity index 100% rename from beest/static/images/frames/hackers debugging together.webp rename to frontend/static/images/frames/hackers debugging together.webp diff --git a/frontend/static/images/hero-1024w.webp b/frontend/static/images/hero-1024w.webp new file mode 100644 index 0000000..2aea1ff Binary files /dev/null and b/frontend/static/images/hero-1024w.webp differ diff --git a/frontend/static/images/hero-1440w.webp b/frontend/static/images/hero-1440w.webp new file mode 100644 index 0000000..2c0cd96 Binary files /dev/null and b/frontend/static/images/hero-1440w.webp differ diff --git a/frontend/static/images/hero-640w.webp b/frontend/static/images/hero-640w.webp new file mode 100644 index 0000000..89f1155 Binary files /dev/null and b/frontend/static/images/hero-640w.webp differ diff --git a/beest/static/images/hero.webp b/frontend/static/images/hero.webp similarity index 100% rename from beest/static/images/hero.webp rename to frontend/static/images/hero.webp diff --git a/frontend/static/images/pipes.png b/frontend/static/images/pipes.png new file mode 100644 index 0000000..3fc777e Binary files /dev/null and b/frontend/static/images/pipes.png differ diff --git a/beest/static/images/shop/blahaj.webp b/frontend/static/images/shop/blahaj.webp similarity index 100% rename from beest/static/images/shop/blahaj.webp rename to frontend/static/images/shop/blahaj.webp diff --git a/beest/static/images/shop/flight-stipend.webp b/frontend/static/images/shop/flight-stipend.webp similarity index 100% rename from beest/static/images/shop/flight-stipend.webp rename to frontend/static/images/shop/flight-stipend.webp diff --git a/beest/static/images/shop/framework.webp b/frontend/static/images/shop/framework.webp similarity index 100% rename from beest/static/images/shop/framework.webp rename to frontend/static/images/shop/framework.webp diff --git a/beest/static/images/shop/headphones.webp b/frontend/static/images/shop/headphones.webp similarity index 100% rename from beest/static/images/shop/headphones.webp rename to frontend/static/images/shop/headphones.webp diff --git a/beest/static/images/shop/polaroid.webp b/frontend/static/images/shop/polaroid.webp similarity index 100% rename from beest/static/images/shop/polaroid.webp rename to frontend/static/images/shop/polaroid.webp diff --git a/beest/static/images/shop/poster.webp b/frontend/static/images/shop/poster.webp similarity index 100% rename from beest/static/images/shop/poster.webp rename to frontend/static/images/shop/poster.webp diff --git a/beest/static/images/shop/printer.webp b/frontend/static/images/shop/printer.webp similarity index 100% rename from beest/static/images/shop/printer.webp rename to frontend/static/images/shop/printer.webp diff --git a/beest/static/images/shop/stickers.webp b/frontend/static/images/shop/stickers.webp similarity index 100% rename from beest/static/images/shop/stickers.webp rename to frontend/static/images/shop/stickers.webp diff --git a/frontend/static/images/sky.webp b/frontend/static/images/sky.webp new file mode 100644 index 0000000..703eded Binary files /dev/null and b/frontend/static/images/sky.webp differ diff --git a/frontend/static/images/sticker.jpg b/frontend/static/images/sticker.jpg new file mode 100644 index 0000000..76a73a8 Binary files /dev/null and b/frontend/static/images/sticker.jpg differ diff --git a/frontend/static/images/sticker.webp b/frontend/static/images/sticker.webp new file mode 100644 index 0000000..8a65086 Binary files /dev/null and b/frontend/static/images/sticker.webp differ diff --git a/frontend/static/images/tile.webp b/frontend/static/images/tile.webp new file mode 100644 index 0000000..bca959d Binary files /dev/null and b/frontend/static/images/tile.webp differ diff --git a/frontend/static/images/tile2.webp b/frontend/static/images/tile2.webp new file mode 100644 index 0000000..a8a3abe Binary files /dev/null and b/frontend/static/images/tile2.webp differ diff --git a/frontend/static/images/tile3.webp b/frontend/static/images/tile3.webp new file mode 100644 index 0000000..5139afd Binary files /dev/null and b/frontend/static/images/tile3.webp differ diff --git a/frontend/static/images/tutorial-top.webp b/frontend/static/images/tutorial-top.webp new file mode 100644 index 0000000..36a714f Binary files /dev/null and b/frontend/static/images/tutorial-top.webp differ diff --git a/beest/static/robots.txt b/frontend/static/robots.txt similarity index 100% rename from beest/static/robots.txt rename to frontend/static/robots.txt diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js new file mode 100644 index 0000000..f3bcb46 --- /dev/null +++ b/frontend/svelte.config.js @@ -0,0 +1,40 @@ +import adapter from '@sveltejs/adapter-node'; + +// CSP is applied to production builds only. In `vite dev`, Vite's HMR client +// uses inline/eval scripts that a strict policy would break — and dev isn't the +// threat surface. Production is where Super Admins issue real HCB grants, so +// that's where the script-src lockdown matters. +const dev = process.env.NODE_ENV !== 'production'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + kit: { + // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. + // If your environment is not supported, or you settled on a specific environment, switch out the adapter. + // See https://svelte.dev/docs/kit/adapters for more information about adapters. + adapter: adapter(), + ...(dev + ? {} + : { + csp: { + // SvelteKit auto-adds hashes/nonces for its own inline scripts. + mode: 'auto', + directives: { + // The critical control: only first-party scripts run. This + // blocks injected