Compare commits

..

65 Commits

Author SHA1 Message Date
Owen 3e3c5cf1c3 Add default dns port and allow sites to be empty 2026-09-09 17:44:43 -04:00
Owen f9752fd6f3 Quiet up logs 2026-09-09 17:43:58 -04:00
Owen a6204ae8da Seperate dns from certificates 2026-09-09 17:02:47 -04:00
Owen 9524a11f25 Wire up to start 2026-09-09 16:50:37 -04:00
Owen 6297759b15 Add certificate generation 2026-09-09 16:42:53 -04:00
Owen 84ff4296f8 Add cert_mode to know when to gen or pull certs 2026-09-09 11:48:17 -04:00
Owen b0a147e10b Adjust lic headers 2026-09-09 11:17:53 -04:00
Owen 9e23a0a6ee Add dns server 2026-09-09 10:43:28 -04:00
Owen 82c5dcf16f Fix tsconfig to use react-jsx 2026-09-08 16:45:56 -04:00
Owen 59f0c90836 Fix circular import 2026-09-08 16:42:22 -04:00
Owen b0e64a5e5a Widen subnet 2026-09-08 16:31:58 -04:00
Owen 733d3ece0e Quiet up error logs 2026-09-08 10:01:59 -04:00
Owen 59b228ce39 Quiet log message 2026-09-08 09:26:57 -04:00
Owen 080bcbaf97 Use endpoint instead of reachableAt for remote nodes 2026-09-07 11:55:45 -04:00
Owen ea9017ac06 Merge branch 'dev' of github.com:fosrl/pangolin into dev 2026-09-04 17:15:33 -04:00
Owen 88770ff97b Refactor form submissions to use startTransition for improved performance 2026-09-04 17:15:20 -04:00
miloschwartz 8e75425887 update screenshots 2026-09-04 15:46:56 -04:00
Owen 44b0186044 Fix yaml import 2026-09-04 15:32:59 -04:00
Owen 063f6b5ca9 Basic DNS config send empty for olm 2026-09-04 12:31:04 -04:00
Owen 778a840ed7 Fix react bug not making it possible to complete security form 2026-09-04 10:08:57 -04:00
Owen 9d19195089 Configurable tab title and disable flag for keys 2026-09-04 09:20:20 -04:00
Owen 54bbe82504 Dont log invalid message type
Fixes #3695
2026-09-04 09:06:58 -04:00
miloschwartz de57df2520 update readme and screenshots 2026-09-03 16:17:07 -04:00
Owen 9e392a967d Add AI disclosure 2026-09-03 15:41:12 -04:00
Owen Schwartz 52f7364c35 Merge pull request #3690 from fosrl/dev
1.22.1
2026-09-03 12:05:18 -04:00
Owen 1bc5fbbf0f Always pull all of the users for the blueprints 2026-09-03 12:02:41 -04:00
Owen be0dd65557 Update imports 2026-09-03 11:35:28 -04:00
Owen 11be7b8ee4 Fix export 2026-09-03 11:24:36 -04:00
Owen 9373cc0408 Revert tanstak components 2026-09-03 11:20:13 -04:00
Owen e50763c340 Pin ts 2026-09-03 11:20:13 -04:00
dependabot[bot] 8826240548 Bump the npm-dependencies group across 1 directory with 74 updates
Bumps the npm-dependencies group with 74 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@asteasolutions/zod-to-openapi](https://github.com/asteasolutions/zod-to-openapi) | `8.5.0` | `9.1.0` |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1056.0` | `3.1121.0` |
| [@hookform/resolvers](https://github.com/react-hook-form/resolvers) | `5.4.0` | `5.9.1` |
| [@node-rs/argon2](https://github.com/napi-rs/node-rs) | `2.0.2` | `2.2.0` |
| [@radix-ui/react-avatar](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/avatar) | `1.1.11` | `1.2.6` |
| [@radix-ui/react-checkbox](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/checkbox) | `1.3.3` | `1.3.11` |
| [@radix-ui/react-collapsible](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/collapsible) | `1.1.12` | `1.1.20` |
| [@radix-ui/react-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dialog) | `1.1.15` | `1.1.23` |
| [@radix-ui/react-dropdown-menu](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dropdown-menu) | `2.1.16` | `2.1.24` |
| [@radix-ui/react-label](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/label) | `2.1.8` | `2.1.15` |
| [@radix-ui/react-popover](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/popover) | `1.1.15` | `1.1.23` |
| [@radix-ui/react-progress](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/progress) | `1.1.8` | `1.1.16` |
| [@radix-ui/react-radio-group](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/radio-group) | `1.3.8` | `1.4.7` |
| [@radix-ui/react-scroll-area](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/scroll-area) | `1.2.10` | `1.2.18` |
| [@radix-ui/react-select](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/select) | `2.2.6` | `2.3.7` |
| [@radix-ui/react-separator](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/separator) | `1.1.8` | `1.1.15` |
| [@radix-ui/react-slot](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slot) | `1.2.4` | `1.3.3` |
| [@radix-ui/react-switch](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/switch) | `1.2.6` | `1.3.7` |
| [@radix-ui/react-tabs](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tabs) | `1.1.13` | `1.1.21` |
| [@radix-ui/react-toast](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/toast) | `1.2.15` | `1.2.23` |
| [@radix-ui/react-tooltip](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tooltip) | `1.2.8` | `1.2.16` |
| [@react-email/render](https://github.com/resend/react-email/tree/HEAD/packages/render) | `2.0.8` | `2.1.0` |
| [@simplewebauthn/server](https://github.com/MasterKale/SimpleWebAuthn/tree/HEAD/packages/server) | `13.3.1` | `13.3.3` |
| [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) | `5.100.14` | `5.102.8` |
| [@tanstack/react-table](https://github.com/TanStack/table/tree/HEAD/packages/react-table) | `8.21.3` | `9.2.4` |
| [axios](https://github.com/axios/axios) | `1.18.0` | `1.20.0` |
| [express-rate-limit](https://github.com/express-rate-limit/express-rate-limit) | `8.5.2` | `8.7.0` |
| [gpt-tokenizer](https://github.com/niieani/gpt-tokenizer) | `3.4.0` | `4.0.0` |
| [helmet](https://github.com/helmetjs/helmet) | `8.2.0` | `8.3.0` |
| [input-otp](https://github.com/guilhermerodz/input-otp/tree/HEAD/packages/input-otp) | `1.4.2` | `1.5.0` |
| [ioredis](https://github.com/redis/ioredis) | `5.11.0` | `6.0.0` |
| [js-yaml](https://github.com/nodeca/js-yaml) | `4.3.1` | `5.4.1` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.17.0` | `1.38.0` |
| [maxmind](https://github.com/runk/node-maxmind) | `5.0.6` | `5.0.7` |
| [next](https://github.com/vercel/next.js) | `16.3.1` | `16.3.3` |
| [next-intl](https://github.com/amannn/next-intl) | `4.13.0` | `4.14.1` |
| [nodemailer](https://github.com/nodemailer/nodemailer) | `9.0.1` | `9.1.0` |
| [pg](https://github.com/brianc/node-postgres/tree/HEAD/packages/pg) | `8.21.0` | `8.23.0` |
| [@types/pg](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/pg) | `8.20.0` | `8.23.1` |
| [posthog-node](https://github.com/PostHog/posthog-js/tree/HEAD/packages/node) | `5.35.6` | `5.51.4` |
| [react](https://github.com/react/react/tree/HEAD/packages/react) | `19.2.6` | `19.2.8` |
| [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) | `19.2.15` | `19.2.18` |
| [react-day-picker](https://github.com/gpbl/react-day-picker/tree/HEAD/packages/react-day-picker) | `9.14.0` | `10.0.1` |
| [react-dom](https://github.com/react/react/tree/HEAD/packages/react-dom) | `19.2.6` | `19.2.8` |
| [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom) | `19.2.3` | `19.2.5` |
| [react-hook-form](https://github.com/react-hook-form/react-hook-form) | `7.76.1` | `7.87.0` |
| [react-icons](https://github.com/react-icons/react-icons) | `5.6.0` | `5.7.0` |
| [recharts](https://github.com/recharts/recharts) | `3.8.1` | `3.10.1` |
| [semver](https://github.com/npm/node-semver) | `7.8.1` | `7.8.5` |
| [@types/semver](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/semver) | `7.7.1` | `7.8.0` |
| [stripe](https://github.com/stripe/stripe-node) | `22.2.0` | `22.6.0` |
| [uuid](https://github.com/uuidjs/uuid) | `14.0.0` | `14.0.2` |
| [ws](https://github.com/websockets/ws) | `8.21.0` | `8.21.3` |
| [yargs](https://github.com/yargs/yargs) | `18.0.0` | `18.1.0` |
| [zod](https://github.com/colinhacks/zod) | `4.4.3` | `4.5.4` |
| [@dotenvx/dotenvx](https://github.com/dotenvx/dotenvx) | `1.69.1` | `2.23.0` |
| [@react-email/ui](https://github.com/resend/react-email/tree/HEAD/packages/ui) | `6.9.2` | `6.9.3` |
| [@tailwindcss/postcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-postcss) | `4.3.0` | `4.3.3` |
| [@tanstack/react-query-devtools](https://github.com/TanStack/query/tree/HEAD/packages/react-query-devtools) | `5.100.14` | `5.102.8` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.9.1` | `26.4.0` |
| [@types/nodemailer](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/nodemailer) | `8.0.0` | `8.0.1` |
| [@types/sshpk](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/sshpk) | `1.17.4` | `1.17.5` |
| [esbuild](https://github.com/evanw/esbuild) | `0.28.0` | `0.28.2` |
| [esbuild-node-externals](https://github.com/pradel/esbuild-node-externals) | `1.22.0` | `2.0.0` |
| [eslint](https://github.com/eslint/eslint) | `10.4.0` | `10.9.1` |
| [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) | `16.2.6` | `16.3.3` |
| [postcss](https://github.com/postcss/postcss) | `8.5.23` | `8.5.26` |
| [prettier](https://github.com/prettier/prettier) | `3.8.3` | `3.9.6` |
| [react-email](https://github.com/resend/react-email/tree/HEAD/packages/react-email) | `6.5.0` | `6.9.3` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.3.0` | `4.3.3` |
| [tsc-alias](https://github.com/justkey007/tsc-alias) | `1.8.17` | `1.9.2` |
| [tsx](https://github.com/privatenumber/tsx) | `4.22.3` | `4.23.13` |
| [typescript](https://github.com/microsoft/TypeScript) | `6.0.3` | `7.0.2` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.60.0` | `8.68.0` |



Updates `@asteasolutions/zod-to-openapi` from 8.5.0 to 9.1.0
- [Release notes](https://github.com/asteasolutions/zod-to-openapi/releases)
- [Commits](https://github.com/asteasolutions/zod-to-openapi/compare/v8.5.0...v9.1.0)

Updates `@aws-sdk/client-s3` from 3.1056.0 to 3.1121.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1121.0/clients/client-s3)

Updates `@hookform/resolvers` from 5.4.0 to 5.9.1
- [Release notes](https://github.com/react-hook-form/resolvers/releases)
- [Commits](https://github.com/react-hook-form/resolvers/compare/v5.4.0...v5.9.1)

Updates `@node-rs/argon2` from 2.0.2 to 2.2.0
- [Release notes](https://github.com/napi-rs/node-rs/releases)
- [Commits](https://github.com/napi-rs/node-rs/compare/@node-rs/argon2@2.0.2...@node-rs/argon2@2.2.0)

Updates `@radix-ui/react-avatar` from 1.1.11 to 1.2.6
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/avatar/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/avatar)

Updates `@radix-ui/react-checkbox` from 1.3.3 to 1.3.11
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/checkbox/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/checkbox)

Updates `@radix-ui/react-collapsible` from 1.1.12 to 1.1.20
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/collapsible/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/collapsible)

Updates `@radix-ui/react-dialog` from 1.1.15 to 1.1.23
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/dialog)

Updates `@radix-ui/react-dropdown-menu` from 2.1.16 to 2.1.24
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/dropdown-menu/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/dropdown-menu)

Updates `@radix-ui/react-label` from 2.1.8 to 2.1.15
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/label/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/label)

Updates `@radix-ui/react-popover` from 1.1.15 to 1.1.23
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/popover/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/popover)

Updates `@radix-ui/react-progress` from 1.1.8 to 1.1.16
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/progress/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/progress)

Updates `@radix-ui/react-radio-group` from 1.3.8 to 1.4.7
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/radio-group/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/radio-group)

Updates `@radix-ui/react-scroll-area` from 1.2.10 to 1.2.18
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/scroll-area/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/scroll-area)

Updates `@radix-ui/react-select` from 2.2.6 to 2.3.7
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/select/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/select)

Updates `@radix-ui/react-separator` from 1.1.8 to 1.1.15
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/separator/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/separator)

Updates `@radix-ui/react-slot` from 1.2.4 to 1.3.3
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slot/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slot)

Updates `@radix-ui/react-switch` from 1.2.6 to 1.3.7
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/switch/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/switch)

Updates `@radix-ui/react-tabs` from 1.1.13 to 1.1.21
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/tabs/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/tabs)

Updates `@radix-ui/react-toast` from 1.2.15 to 1.2.23
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/toast/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/toast)

Updates `@radix-ui/react-tooltip` from 1.2.8 to 1.2.16
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/tooltip/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/tooltip)

Updates `@react-email/render` from 2.0.8 to 2.1.0
- [Release notes](https://github.com/resend/react-email/releases)
- [Changelog](https://github.com/resend/react-email/blob/canary/packages/render/CHANGELOG.md)
- [Commits](https://github.com/resend/react-email/commits/@react-email/render@2.1.0/packages/render)

Updates `@simplewebauthn/server` from 13.3.1 to 13.3.3
- [Release notes](https://github.com/MasterKale/SimpleWebAuthn/releases)
- [Changelog](https://github.com/MasterKale/SimpleWebAuthn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/MasterKale/SimpleWebAuthn/commits/v13.3.3/packages/server)

Updates `@tanstack/react-query` from 5.100.14 to 5.102.8
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.102.8/packages/react-query)

Updates `@tanstack/react-table` from 8.21.3 to 9.2.4
- [Release notes](https://github.com/TanStack/table/releases)
- [Changelog](https://github.com/TanStack/table/blob/main/packages/react-table/CHANGELOG.md)
- [Commits](https://github.com/TanStack/table/commits/@tanstack/react-table@9.2.4/packages/react-table)

Updates `axios` from 1.18.0 to 1.20.0
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.18.0...v1.20.0)

Updates `express-rate-limit` from 8.5.2 to 8.7.0
- [Release notes](https://github.com/express-rate-limit/express-rate-limit/releases)
- [Commits](https://github.com/express-rate-limit/express-rate-limit/compare/v8.5.2...v8.7.0)

Updates `gpt-tokenizer` from 3.4.0 to 4.0.0
- [Release notes](https://github.com/niieani/gpt-tokenizer/releases)
- [Commits](https://github.com/niieani/gpt-tokenizer/compare/3.4.0...4.0.0)

Updates `helmet` from 8.2.0 to 8.3.0
- [Changelog](https://github.com/helmetjs/helmet/blob/main/CHANGELOG.md)
- [Commits](https://github.com/helmetjs/helmet/compare/v8.2.0...v8.3.0)

Updates `input-otp` from 1.4.2 to 1.5.0
- [Release notes](https://github.com/guilhermerodz/input-otp/releases)
- [Changelog](https://github.com/guilhermerodz/input-otp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/guilhermerodz/input-otp/commits/v1.5.0/packages/input-otp)

Updates `ioredis` from 5.11.0 to 6.0.0
- [Release notes](https://github.com/redis/ioredis/releases)
- [Changelog](https://github.com/redis/ioredis/blob/main/CHANGELOG.md)
- [Commits](https://github.com/redis/ioredis/compare/v5.11.0...v6.0.0)

Updates `js-yaml` from 4.3.1 to 5.4.1
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.3.1...5.4.1)

Updates `lucide-react` from 1.17.0 to 1.38.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.38.0/packages/lucide-react)

Updates `maxmind` from 5.0.6 to 5.0.7
- [Release notes](https://github.com/runk/node-maxmind/releases)
- [Commits](https://github.com/runk/node-maxmind/compare/v5.0.6...v5.0.7)

Updates `next` from 16.3.1 to 16.3.3
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.3.1...v16.3.3)

Updates `next-intl` from 4.13.0 to 4.14.1
- [Release notes](https://github.com/amannn/next-intl/releases)
- [Changelog](https://github.com/amannn/next-intl/blob/main/CHANGELOG.md)
- [Commits](https://github.com/amannn/next-intl/compare/v4.13.0...v4.14.1)

Updates `nodemailer` from 9.0.1 to 9.1.0
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v9.0.1...v9.1.0)

Updates `pg` from 8.21.0 to 8.23.0
- [Changelog](https://github.com/brianc/node-postgres/blob/master/CHANGELOG.md)
- [Commits](https://github.com/brianc/node-postgres/commits/pg@8.23.0/packages/pg)

Updates `@types/pg` from 8.20.0 to 8.23.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/pg)

Updates `posthog-node` from 5.35.6 to 5.51.4
- [Release notes](https://github.com/PostHog/posthog-js/releases)
- [Changelog](https://github.com/PostHog/posthog-js/blob/main/packages/node/CHANGELOG.md)
- [Commits](https://github.com/PostHog/posthog-js/commits/posthog-node@5.51.4/packages/node)

Updates `react` from 19.2.6 to 19.2.8
- [Release notes](https://github.com/react/react/releases)
- [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/react/react/commits/v19.2.8/packages/react)

Updates `@types/react` from 19.2.15 to 19.2.18
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

Updates `react-day-picker` from 9.14.0 to 10.0.1
- [Release notes](https://github.com/gpbl/react-day-picker/releases)
- [Changelog](https://github.com/gpbl/react-day-picker/blob/main/packages/react-day-picker/CHANGELOG.md)
- [Commits](https://github.com/gpbl/react-day-picker/commits/v10.0.1/packages/react-day-picker)

Updates `react-dom` from 19.2.6 to 19.2.8
- [Release notes](https://github.com/react/react/releases)
- [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/react/react/commits/v19.2.8/packages/react-dom)

Updates `@types/react-dom` from 19.2.3 to 19.2.5
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

Updates `react-hook-form` from 7.76.1 to 7.87.0
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.76.1...v7.87.0)

Updates `react-icons` from 5.6.0 to 5.7.0
- [Release notes](https://github.com/react-icons/react-icons/releases)
- [Commits](https://github.com/react-icons/react-icons/compare/v5.6.0...v5.7.0)

Updates `recharts` from 3.8.1 to 3.10.1
- [Release notes](https://github.com/recharts/recharts/releases)
- [Changelog](https://github.com/recharts/recharts/blob/main/CHANGELOG.md)
- [Commits](https://github.com/recharts/recharts/compare/v3.8.1...v3.10.1)

Updates `semver` from 7.8.1 to 7.8.5
- [Release notes](https://github.com/npm/node-semver/releases)
- [Changelog](https://github.com/npm/node-semver/blob/main/CHANGELOG.md)
- [Commits](https://github.com/npm/node-semver/compare/v7.8.1...v7.8.5)

Updates `@types/semver` from 7.7.1 to 7.8.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/semver)

Updates `stripe` from 22.2.0 to 22.6.0
- [Release notes](https://github.com/stripe/stripe-node/releases)
- [Changelog](https://github.com/stripe/stripe-node/blob/master/CHANGELOG.md)
- [Commits](https://github.com/stripe/stripe-node/compare/v22.2.0...v22.6.0)

Updates `uuid` from 14.0.0 to 14.0.2
- [Release notes](https://github.com/uuidjs/uuid/releases)
- [Changelog](https://github.com/uuidjs/uuid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/uuidjs/uuid/compare/v14.0.0...v14.0.2)

Updates `ws` from 8.21.0 to 8.21.3
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.21.0...8.21.3)

Updates `yargs` from 18.0.0 to 18.1.0
- [Release notes](https://github.com/yargs/yargs/releases)
- [Changelog](https://github.com/yargs/yargs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/yargs/yargs/compare/v18.0.0...v18.1.0)

Updates `zod` from 4.4.3 to 4.5.4
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v4.4.3...v4.5.4)

Updates `@dotenvx/dotenvx` from 1.69.1 to 2.23.0
- [Release notes](https://github.com/dotenvx/dotenvx/releases)
- [Changelog](https://github.com/dotenvx/dotenvx/blob/main/CHANGELOG.md)
- [Commits](https://github.com/dotenvx/dotenvx/compare/v1.69.1...v2.23.0)

Updates `@react-email/ui` from 6.9.2 to 6.9.3
- [Release notes](https://github.com/resend/react-email/releases)
- [Changelog](https://github.com/resend/react-email/blob/canary/packages/ui/CHANGELOG.md)
- [Commits](https://github.com/resend/react-email/commits/@react-email/ui@6.9.3/packages/ui)

Updates `@tailwindcss/postcss` from 4.3.0 to 4.3.3
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.3/packages/@tailwindcss-postcss)

Updates `@tanstack/react-query-devtools` from 5.100.14 to 5.102.8
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query-devtools/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query-devtools@5.102.8/packages/react-query-devtools)

Updates `@types/node` from 25.9.1 to 26.4.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `@types/nodemailer` from 8.0.0 to 8.0.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/nodemailer)

Updates `@types/pg` from 8.20.0 to 8.23.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/pg)

Updates `@types/react` from 19.2.15 to 19.2.18
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

Updates `@types/react-dom` from 19.2.3 to 19.2.5
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

Updates `@types/semver` from 7.7.1 to 7.8.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/semver)

Updates `@types/sshpk` from 1.17.4 to 1.17.5
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/sshpk)

Updates `esbuild` from 0.28.0 to 0.28.2
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.28.0...v0.28.2)

Updates `esbuild-node-externals` from 1.22.0 to 2.0.0
- [Release notes](https://github.com/pradel/esbuild-node-externals/releases)
- [Commits](https://github.com/pradel/esbuild-node-externals/compare/esbuild-node-externals-v1.22.0...esbuild-node-externals-v2.0.0)

Updates `eslint` from 10.4.0 to 10.9.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.4.0...v10.9.1)

Updates `eslint-config-next` from 16.2.6 to 16.3.3
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.3.3/packages/eslint-config-next)

Updates `postcss` from 8.5.23 to 8.5.26
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.23...8.5.26)

Updates `prettier` from 3.8.3 to 3.9.6
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.8.3...3.9.6)

Updates `react-email` from 6.5.0 to 6.9.3
- [Release notes](https://github.com/resend/react-email/releases)
- [Changelog](https://github.com/resend/react-email/blob/canary/packages/react-email/CHANGELOG.md)
- [Commits](https://github.com/resend/react-email/commits/react-email@6.9.3/packages/react-email)

Updates `tailwindcss` from 4.3.0 to 4.3.3
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.3/packages/tailwindcss)

Updates `tsc-alias` from 1.8.17 to 1.9.2
- [Release notes](https://github.com/justkey007/tsc-alias/releases)
- [Commits](https://github.com/justkey007/tsc-alias/compare/v1.8.17...v1.9.2)

Updates `tsx` from 4.22.3 to 4.23.13
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.22.3...v4.23.13)

Updates `typescript` from 6.0.3 to 7.0.2
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v6.0.3...v7.0.2)

Updates `typescript-eslint` from 8.60.0 to 8.68.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.68.0/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: "@asteasolutions/zod-to-openapi"
  dependency-version: 9.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.1121.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@dotenvx/dotenvx"
  dependency-version: 2.22.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: npm-dependencies
- dependency-name: "@hookform/resolvers"
  dependency-version: 5.9.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@node-rs/argon2"
  dependency-version: 2.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-avatar"
  dependency-version: 1.2.6
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-checkbox"
  dependency-version: 1.3.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-collapsible"
  dependency-version: 1.1.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-dialog"
  dependency-version: 1.1.23
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-dropdown-menu"
  dependency-version: 2.1.24
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-label"
  dependency-version: 2.1.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-popover"
  dependency-version: 1.1.23
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-progress"
  dependency-version: 1.1.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-radio-group"
  dependency-version: 1.4.7
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-scroll-area"
  dependency-version: 1.2.18
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-select"
  dependency-version: 2.3.7
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-separator"
  dependency-version: 1.1.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-slot"
  dependency-version: 1.3.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-switch"
  dependency-version: 1.3.7
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-tabs"
  dependency-version: 1.1.21
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-toast"
  dependency-version: 1.2.23
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-tooltip"
  dependency-version: 1.2.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@react-email/render"
  dependency-version: 2.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@react-email/ui"
  dependency-version: 6.9.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@simplewebauthn/server"
  dependency-version: 13.3.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@tailwindcss/postcss"
  dependency-version: 4.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.102.8
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@tanstack/react-query-devtools"
  dependency-version: 5.102.8
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@tanstack/react-table"
  dependency-version: 9.2.4
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: npm-dependencies
- dependency-name: "@types/node"
  dependency-version: 26.4.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: npm-dependencies
- dependency-name: "@types/nodemailer"
  dependency-version: 8.0.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@types/pg"
  dependency-version: 8.23.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@types/pg"
  dependency-version: 8.23.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@types/react"
  dependency-version: 19.2.18
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@types/react"
  dependency-version: 19.2.18
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@types/react-dom"
  dependency-version: 19.2.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@types/react-dom"
  dependency-version: 19.2.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@types/semver"
  dependency-version: 7.8.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@types/semver"
  dependency-version: 7.8.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@types/sshpk"
  dependency-version: 1.17.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: axios
  dependency-version: 1.20.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: esbuild
  dependency-version: 0.28.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: esbuild-node-externals
  dependency-version: 2.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: npm-dependencies
- dependency-name: eslint
  dependency-version: 10.9.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: eslint-config-next
  dependency-version: 16.3.3
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: express-rate-limit
  dependency-version: 8.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: gpt-tokenizer
  dependency-version: 4.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: npm-dependencies
- dependency-name: helmet
  dependency-version: 8.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: input-otp
  dependency-version: 1.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: ioredis
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: npm-dependencies
- dependency-name: js-yaml
  dependency-version: 5.4.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: npm-dependencies
- dependency-name: lucide-react
  dependency-version: 1.35.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: maxmind
  dependency-version: 5.0.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: next
  dependency-version: 16.3.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: next-intl
  dependency-version: 4.14.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: nodemailer
  dependency-version: 9.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: pg
  dependency-version: 8.23.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: postcss
  dependency-version: 8.5.26
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: posthog-node
  dependency-version: 5.51.4
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: prettier
  dependency-version: 3.9.6
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: react
  dependency-version: 19.2.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: react-day-picker
  dependency-version: 10.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: npm-dependencies
- dependency-name: react-dom
  dependency-version: 19.2.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: react-email
  dependency-version: 6.9.3
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: react-hook-form
  dependency-version: 7.86.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: react-icons
  dependency-version: 5.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: recharts
  dependency-version: 3.10.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: semver
  dependency-version: 7.8.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: stripe
  dependency-version: 22.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: tailwindcss
  dependency-version: 4.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: tsc-alias
  dependency-version: 1.9.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: tsx
  dependency-version: 4.23.12
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: typescript
  dependency-version: 7.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: npm-dependencies
- dependency-name: typescript-eslint
  dependency-version: 8.68.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: uuid
  dependency-version: 14.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: ws
  dependency-version: 8.21.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: yargs
  dependency-version: 18.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: zod
  dependency-version: 4.5.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-03 11:20:13 -04:00
dependabot[bot] 6d83614482 Bump browserslist from 4.28.2 to 4.28.8
Bumps [browserslist](https://github.com/browserslist/browserslist) from 4.28.2 to 4.28.8.
- [Release notes](https://github.com/browserslist/browserslist/releases)
- [Changelog](https://github.com/browserslist/browserslist/blob/main/CHANGELOG.md)
- [Commits](https://github.com/browserslist/browserslist/compare/4.28.2...4.28.8)

---
updated-dependencies:
- dependency-name: browserslist
  dependency-version: 4.28.8
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-03 11:20:13 -04:00
dependabot[bot] c319b7a65f Bump qs from 6.15.2 to 6.16.0
Bumps [qs](https://github.com/ljharb/qs) from 6.15.2 to 6.16.0.
- [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/qs/compare/v6.15.2...v6.16.0)

---
updated-dependencies:
- dependency-name: qs
  dependency-version: 6.16.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-03 11:20:12 -04:00
dependabot[bot] aa7fe7ee0e Bump fast-uri from 3.1.5 to 3.1.7
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.5 to 3.1.7.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.5...v3.1.7)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-03 11:20:12 -04:00
Owen Schwartz 5fd71df2ae Merge pull request #3672 from fosrl/dependabot/npm_and_yarn/npm-dependencies-6e680b3dcf
Bump the npm-dependencies group across 1 directory with 74 updates
2026-09-03 11:19:47 -04:00
Owen c7e1462e46 Revert tanstak components 2026-09-03 11:16:12 -04:00
Owen cfb73c9e21 Pin ts 2026-09-03 11:11:05 -04:00
dependabot[bot] 72a9040c2e Bump the npm-dependencies group across 1 directory with 74 updates
Bumps the npm-dependencies group with 74 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@asteasolutions/zod-to-openapi](https://github.com/asteasolutions/zod-to-openapi) | `8.5.0` | `9.1.0` |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1056.0` | `3.1121.0` |
| [@hookform/resolvers](https://github.com/react-hook-form/resolvers) | `5.4.0` | `5.9.1` |
| [@node-rs/argon2](https://github.com/napi-rs/node-rs) | `2.0.2` | `2.2.0` |
| [@radix-ui/react-avatar](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/avatar) | `1.1.11` | `1.2.6` |
| [@radix-ui/react-checkbox](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/checkbox) | `1.3.3` | `1.3.11` |
| [@radix-ui/react-collapsible](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/collapsible) | `1.1.12` | `1.1.20` |
| [@radix-ui/react-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dialog) | `1.1.15` | `1.1.23` |
| [@radix-ui/react-dropdown-menu](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dropdown-menu) | `2.1.16` | `2.1.24` |
| [@radix-ui/react-label](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/label) | `2.1.8` | `2.1.15` |
| [@radix-ui/react-popover](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/popover) | `1.1.15` | `1.1.23` |
| [@radix-ui/react-progress](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/progress) | `1.1.8` | `1.1.16` |
| [@radix-ui/react-radio-group](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/radio-group) | `1.3.8` | `1.4.7` |
| [@radix-ui/react-scroll-area](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/scroll-area) | `1.2.10` | `1.2.18` |
| [@radix-ui/react-select](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/select) | `2.2.6` | `2.3.7` |
| [@radix-ui/react-separator](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/separator) | `1.1.8` | `1.1.15` |
| [@radix-ui/react-slot](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slot) | `1.2.4` | `1.3.3` |
| [@radix-ui/react-switch](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/switch) | `1.2.6` | `1.3.7` |
| [@radix-ui/react-tabs](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tabs) | `1.1.13` | `1.1.21` |
| [@radix-ui/react-toast](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/toast) | `1.2.15` | `1.2.23` |
| [@radix-ui/react-tooltip](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tooltip) | `1.2.8` | `1.2.16` |
| [@react-email/render](https://github.com/resend/react-email/tree/HEAD/packages/render) | `2.0.8` | `2.1.0` |
| [@simplewebauthn/server](https://github.com/MasterKale/SimpleWebAuthn/tree/HEAD/packages/server) | `13.3.1` | `13.3.3` |
| [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) | `5.100.14` | `5.102.8` |
| [@tanstack/react-table](https://github.com/TanStack/table/tree/HEAD/packages/react-table) | `8.21.3` | `9.2.4` |
| [axios](https://github.com/axios/axios) | `1.18.0` | `1.20.0` |
| [express-rate-limit](https://github.com/express-rate-limit/express-rate-limit) | `8.5.2` | `8.7.0` |
| [gpt-tokenizer](https://github.com/niieani/gpt-tokenizer) | `3.4.0` | `4.0.0` |
| [helmet](https://github.com/helmetjs/helmet) | `8.2.0` | `8.3.0` |
| [input-otp](https://github.com/guilhermerodz/input-otp/tree/HEAD/packages/input-otp) | `1.4.2` | `1.5.0` |
| [ioredis](https://github.com/redis/ioredis) | `5.11.0` | `6.0.0` |
| [js-yaml](https://github.com/nodeca/js-yaml) | `4.3.1` | `5.4.1` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.17.0` | `1.38.0` |
| [maxmind](https://github.com/runk/node-maxmind) | `5.0.6` | `5.0.7` |
| [next](https://github.com/vercel/next.js) | `16.3.1` | `16.3.3` |
| [next-intl](https://github.com/amannn/next-intl) | `4.13.0` | `4.14.1` |
| [nodemailer](https://github.com/nodemailer/nodemailer) | `9.0.1` | `9.1.0` |
| [pg](https://github.com/brianc/node-postgres/tree/HEAD/packages/pg) | `8.21.0` | `8.23.0` |
| [@types/pg](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/pg) | `8.20.0` | `8.23.1` |
| [posthog-node](https://github.com/PostHog/posthog-js/tree/HEAD/packages/node) | `5.35.6` | `5.51.4` |
| [react](https://github.com/react/react/tree/HEAD/packages/react) | `19.2.6` | `19.2.8` |
| [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) | `19.2.15` | `19.2.18` |
| [react-day-picker](https://github.com/gpbl/react-day-picker/tree/HEAD/packages/react-day-picker) | `9.14.0` | `10.0.1` |
| [react-dom](https://github.com/react/react/tree/HEAD/packages/react-dom) | `19.2.6` | `19.2.8` |
| [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom) | `19.2.3` | `19.2.5` |
| [react-hook-form](https://github.com/react-hook-form/react-hook-form) | `7.76.1` | `7.87.0` |
| [react-icons](https://github.com/react-icons/react-icons) | `5.6.0` | `5.7.0` |
| [recharts](https://github.com/recharts/recharts) | `3.8.1` | `3.10.1` |
| [semver](https://github.com/npm/node-semver) | `7.8.1` | `7.8.5` |
| [@types/semver](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/semver) | `7.7.1` | `7.8.0` |
| [stripe](https://github.com/stripe/stripe-node) | `22.2.0` | `22.6.0` |
| [uuid](https://github.com/uuidjs/uuid) | `14.0.0` | `14.0.2` |
| [ws](https://github.com/websockets/ws) | `8.21.0` | `8.21.3` |
| [yargs](https://github.com/yargs/yargs) | `18.0.0` | `18.1.0` |
| [zod](https://github.com/colinhacks/zod) | `4.4.3` | `4.5.4` |
| [@dotenvx/dotenvx](https://github.com/dotenvx/dotenvx) | `1.69.1` | `2.23.0` |
| [@react-email/ui](https://github.com/resend/react-email/tree/HEAD/packages/ui) | `6.9.2` | `6.9.3` |
| [@tailwindcss/postcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-postcss) | `4.3.0` | `4.3.3` |
| [@tanstack/react-query-devtools](https://github.com/TanStack/query/tree/HEAD/packages/react-query-devtools) | `5.100.14` | `5.102.8` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.9.1` | `26.4.0` |
| [@types/nodemailer](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/nodemailer) | `8.0.0` | `8.0.1` |
| [@types/sshpk](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/sshpk) | `1.17.4` | `1.17.5` |
| [esbuild](https://github.com/evanw/esbuild) | `0.28.0` | `0.28.2` |
| [esbuild-node-externals](https://github.com/pradel/esbuild-node-externals) | `1.22.0` | `2.0.0` |
| [eslint](https://github.com/eslint/eslint) | `10.4.0` | `10.9.1` |
| [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) | `16.2.6` | `16.3.3` |
| [postcss](https://github.com/postcss/postcss) | `8.5.23` | `8.5.26` |
| [prettier](https://github.com/prettier/prettier) | `3.8.3` | `3.9.6` |
| [react-email](https://github.com/resend/react-email/tree/HEAD/packages/react-email) | `6.5.0` | `6.9.3` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.3.0` | `4.3.3` |
| [tsc-alias](https://github.com/justkey007/tsc-alias) | `1.8.17` | `1.9.2` |
| [tsx](https://github.com/privatenumber/tsx) | `4.22.3` | `4.23.13` |
| [typescript](https://github.com/microsoft/TypeScript) | `6.0.3` | `7.0.2` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.60.0` | `8.68.0` |



Updates `@asteasolutions/zod-to-openapi` from 8.5.0 to 9.1.0
- [Release notes](https://github.com/asteasolutions/zod-to-openapi/releases)
- [Commits](https://github.com/asteasolutions/zod-to-openapi/compare/v8.5.0...v9.1.0)

Updates `@aws-sdk/client-s3` from 3.1056.0 to 3.1121.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1121.0/clients/client-s3)

Updates `@hookform/resolvers` from 5.4.0 to 5.9.1
- [Release notes](https://github.com/react-hook-form/resolvers/releases)
- [Commits](https://github.com/react-hook-form/resolvers/compare/v5.4.0...v5.9.1)

Updates `@node-rs/argon2` from 2.0.2 to 2.2.0
- [Release notes](https://github.com/napi-rs/node-rs/releases)
- [Commits](https://github.com/napi-rs/node-rs/compare/@node-rs/argon2@2.0.2...@node-rs/argon2@2.2.0)

Updates `@radix-ui/react-avatar` from 1.1.11 to 1.2.6
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/avatar/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/avatar)

Updates `@radix-ui/react-checkbox` from 1.3.3 to 1.3.11
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/checkbox/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/checkbox)

Updates `@radix-ui/react-collapsible` from 1.1.12 to 1.1.20
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/collapsible/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/collapsible)

Updates `@radix-ui/react-dialog` from 1.1.15 to 1.1.23
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/dialog)

Updates `@radix-ui/react-dropdown-menu` from 2.1.16 to 2.1.24
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/dropdown-menu/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/dropdown-menu)

Updates `@radix-ui/react-label` from 2.1.8 to 2.1.15
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/label/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/label)

Updates `@radix-ui/react-popover` from 1.1.15 to 1.1.23
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/popover/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/popover)

Updates `@radix-ui/react-progress` from 1.1.8 to 1.1.16
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/progress/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/progress)

Updates `@radix-ui/react-radio-group` from 1.3.8 to 1.4.7
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/radio-group/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/radio-group)

Updates `@radix-ui/react-scroll-area` from 1.2.10 to 1.2.18
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/scroll-area/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/scroll-area)

Updates `@radix-ui/react-select` from 2.2.6 to 2.3.7
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/select/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/select)

Updates `@radix-ui/react-separator` from 1.1.8 to 1.1.15
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/separator/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/separator)

Updates `@radix-ui/react-slot` from 1.2.4 to 1.3.3
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slot/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slot)

Updates `@radix-ui/react-switch` from 1.2.6 to 1.3.7
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/switch/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/switch)

Updates `@radix-ui/react-tabs` from 1.1.13 to 1.1.21
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/tabs/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/tabs)

Updates `@radix-ui/react-toast` from 1.2.15 to 1.2.23
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/toast/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/toast)

Updates `@radix-ui/react-tooltip` from 1.2.8 to 1.2.16
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/tooltip/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/tooltip)

Updates `@react-email/render` from 2.0.8 to 2.1.0
- [Release notes](https://github.com/resend/react-email/releases)
- [Changelog](https://github.com/resend/react-email/blob/canary/packages/render/CHANGELOG.md)
- [Commits](https://github.com/resend/react-email/commits/@react-email/render@2.1.0/packages/render)

Updates `@simplewebauthn/server` from 13.3.1 to 13.3.3
- [Release notes](https://github.com/MasterKale/SimpleWebAuthn/releases)
- [Changelog](https://github.com/MasterKale/SimpleWebAuthn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/MasterKale/SimpleWebAuthn/commits/v13.3.3/packages/server)

Updates `@tanstack/react-query` from 5.100.14 to 5.102.8
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.102.8/packages/react-query)

Updates `@tanstack/react-table` from 8.21.3 to 9.2.4
- [Release notes](https://github.com/TanStack/table/releases)
- [Changelog](https://github.com/TanStack/table/blob/main/packages/react-table/CHANGELOG.md)
- [Commits](https://github.com/TanStack/table/commits/@tanstack/react-table@9.2.4/packages/react-table)

Updates `axios` from 1.18.0 to 1.20.0
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.18.0...v1.20.0)

Updates `express-rate-limit` from 8.5.2 to 8.7.0
- [Release notes](https://github.com/express-rate-limit/express-rate-limit/releases)
- [Commits](https://github.com/express-rate-limit/express-rate-limit/compare/v8.5.2...v8.7.0)

Updates `gpt-tokenizer` from 3.4.0 to 4.0.0
- [Release notes](https://github.com/niieani/gpt-tokenizer/releases)
- [Commits](https://github.com/niieani/gpt-tokenizer/compare/3.4.0...4.0.0)

Updates `helmet` from 8.2.0 to 8.3.0
- [Changelog](https://github.com/helmetjs/helmet/blob/main/CHANGELOG.md)
- [Commits](https://github.com/helmetjs/helmet/compare/v8.2.0...v8.3.0)

Updates `input-otp` from 1.4.2 to 1.5.0
- [Release notes](https://github.com/guilhermerodz/input-otp/releases)
- [Changelog](https://github.com/guilhermerodz/input-otp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/guilhermerodz/input-otp/commits/v1.5.0/packages/input-otp)

Updates `ioredis` from 5.11.0 to 6.0.0
- [Release notes](https://github.com/redis/ioredis/releases)
- [Changelog](https://github.com/redis/ioredis/blob/main/CHANGELOG.md)
- [Commits](https://github.com/redis/ioredis/compare/v5.11.0...v6.0.0)

Updates `js-yaml` from 4.3.1 to 5.4.1
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.3.1...5.4.1)

Updates `lucide-react` from 1.17.0 to 1.38.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.38.0/packages/lucide-react)

Updates `maxmind` from 5.0.6 to 5.0.7
- [Release notes](https://github.com/runk/node-maxmind/releases)
- [Commits](https://github.com/runk/node-maxmind/compare/v5.0.6...v5.0.7)

Updates `next` from 16.3.1 to 16.3.3
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.3.1...v16.3.3)

Updates `next-intl` from 4.13.0 to 4.14.1
- [Release notes](https://github.com/amannn/next-intl/releases)
- [Changelog](https://github.com/amannn/next-intl/blob/main/CHANGELOG.md)
- [Commits](https://github.com/amannn/next-intl/compare/v4.13.0...v4.14.1)

Updates `nodemailer` from 9.0.1 to 9.1.0
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v9.0.1...v9.1.0)

Updates `pg` from 8.21.0 to 8.23.0
- [Changelog](https://github.com/brianc/node-postgres/blob/master/CHANGELOG.md)
- [Commits](https://github.com/brianc/node-postgres/commits/pg@8.23.0/packages/pg)

Updates `@types/pg` from 8.20.0 to 8.23.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/pg)

Updates `posthog-node` from 5.35.6 to 5.51.4
- [Release notes](https://github.com/PostHog/posthog-js/releases)
- [Changelog](https://github.com/PostHog/posthog-js/blob/main/packages/node/CHANGELOG.md)
- [Commits](https://github.com/PostHog/posthog-js/commits/posthog-node@5.51.4/packages/node)

Updates `react` from 19.2.6 to 19.2.8
- [Release notes](https://github.com/react/react/releases)
- [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/react/react/commits/v19.2.8/packages/react)

Updates `@types/react` from 19.2.15 to 19.2.18
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

Updates `react-day-picker` from 9.14.0 to 10.0.1
- [Release notes](https://github.com/gpbl/react-day-picker/releases)
- [Changelog](https://github.com/gpbl/react-day-picker/blob/main/packages/react-day-picker/CHANGELOG.md)
- [Commits](https://github.com/gpbl/react-day-picker/commits/v10.0.1/packages/react-day-picker)

Updates `react-dom` from 19.2.6 to 19.2.8
- [Release notes](https://github.com/react/react/releases)
- [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/react/react/commits/v19.2.8/packages/react-dom)

Updates `@types/react-dom` from 19.2.3 to 19.2.5
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

Updates `react-hook-form` from 7.76.1 to 7.87.0
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.76.1...v7.87.0)

Updates `react-icons` from 5.6.0 to 5.7.0
- [Release notes](https://github.com/react-icons/react-icons/releases)
- [Commits](https://github.com/react-icons/react-icons/compare/v5.6.0...v5.7.0)

Updates `recharts` from 3.8.1 to 3.10.1
- [Release notes](https://github.com/recharts/recharts/releases)
- [Changelog](https://github.com/recharts/recharts/blob/main/CHANGELOG.md)
- [Commits](https://github.com/recharts/recharts/compare/v3.8.1...v3.10.1)

Updates `semver` from 7.8.1 to 7.8.5
- [Release notes](https://github.com/npm/node-semver/releases)
- [Changelog](https://github.com/npm/node-semver/blob/main/CHANGELOG.md)
- [Commits](https://github.com/npm/node-semver/compare/v7.8.1...v7.8.5)

Updates `@types/semver` from 7.7.1 to 7.8.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/semver)

Updates `stripe` from 22.2.0 to 22.6.0
- [Release notes](https://github.com/stripe/stripe-node/releases)
- [Changelog](https://github.com/stripe/stripe-node/blob/master/CHANGELOG.md)
- [Commits](https://github.com/stripe/stripe-node/compare/v22.2.0...v22.6.0)

Updates `uuid` from 14.0.0 to 14.0.2
- [Release notes](https://github.com/uuidjs/uuid/releases)
- [Changelog](https://github.com/uuidjs/uuid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/uuidjs/uuid/compare/v14.0.0...v14.0.2)

Updates `ws` from 8.21.0 to 8.21.3
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.21.0...8.21.3)

Updates `yargs` from 18.0.0 to 18.1.0
- [Release notes](https://github.com/yargs/yargs/releases)
- [Changelog](https://github.com/yargs/yargs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/yargs/yargs/compare/v18.0.0...v18.1.0)

Updates `zod` from 4.4.3 to 4.5.4
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v4.4.3...v4.5.4)

Updates `@dotenvx/dotenvx` from 1.69.1 to 2.23.0
- [Release notes](https://github.com/dotenvx/dotenvx/releases)
- [Changelog](https://github.com/dotenvx/dotenvx/blob/main/CHANGELOG.md)
- [Commits](https://github.com/dotenvx/dotenvx/compare/v1.69.1...v2.23.0)

Updates `@react-email/ui` from 6.9.2 to 6.9.3
- [Release notes](https://github.com/resend/react-email/releases)
- [Changelog](https://github.com/resend/react-email/blob/canary/packages/ui/CHANGELOG.md)
- [Commits](https://github.com/resend/react-email/commits/@react-email/ui@6.9.3/packages/ui)

Updates `@tailwindcss/postcss` from 4.3.0 to 4.3.3
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.3/packages/@tailwindcss-postcss)

Updates `@tanstack/react-query-devtools` from 5.100.14 to 5.102.8
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query-devtools/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query-devtools@5.102.8/packages/react-query-devtools)

Updates `@types/node` from 25.9.1 to 26.4.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `@types/nodemailer` from 8.0.0 to 8.0.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/nodemailer)

Updates `@types/pg` from 8.20.0 to 8.23.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/pg)

Updates `@types/react` from 19.2.15 to 19.2.18
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

Updates `@types/react-dom` from 19.2.3 to 19.2.5
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

Updates `@types/semver` from 7.7.1 to 7.8.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/semver)

Updates `@types/sshpk` from 1.17.4 to 1.17.5
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/sshpk)

Updates `esbuild` from 0.28.0 to 0.28.2
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.28.0...v0.28.2)

Updates `esbuild-node-externals` from 1.22.0 to 2.0.0
- [Release notes](https://github.com/pradel/esbuild-node-externals/releases)
- [Commits](https://github.com/pradel/esbuild-node-externals/compare/esbuild-node-externals-v1.22.0...esbuild-node-externals-v2.0.0)

Updates `eslint` from 10.4.0 to 10.9.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.4.0...v10.9.1)

Updates `eslint-config-next` from 16.2.6 to 16.3.3
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.3.3/packages/eslint-config-next)

Updates `postcss` from 8.5.23 to 8.5.26
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.23...8.5.26)

Updates `prettier` from 3.8.3 to 3.9.6
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.8.3...3.9.6)

Updates `react-email` from 6.5.0 to 6.9.3
- [Release notes](https://github.com/resend/react-email/releases)
- [Changelog](https://github.com/resend/react-email/blob/canary/packages/react-email/CHANGELOG.md)
- [Commits](https://github.com/resend/react-email/commits/react-email@6.9.3/packages/react-email)

Updates `tailwindcss` from 4.3.0 to 4.3.3
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.3/packages/tailwindcss)

Updates `tsc-alias` from 1.8.17 to 1.9.2
- [Release notes](https://github.com/justkey007/tsc-alias/releases)
- [Commits](https://github.com/justkey007/tsc-alias/compare/v1.8.17...v1.9.2)

Updates `tsx` from 4.22.3 to 4.23.13
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.22.3...v4.23.13)

Updates `typescript` from 6.0.3 to 7.0.2
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v6.0.3...v7.0.2)

Updates `typescript-eslint` from 8.60.0 to 8.68.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.68.0/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: "@asteasolutions/zod-to-openapi"
  dependency-version: 9.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.1121.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@dotenvx/dotenvx"
  dependency-version: 2.22.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: npm-dependencies
- dependency-name: "@hookform/resolvers"
  dependency-version: 5.9.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@node-rs/argon2"
  dependency-version: 2.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-avatar"
  dependency-version: 1.2.6
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-checkbox"
  dependency-version: 1.3.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-collapsible"
  dependency-version: 1.1.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-dialog"
  dependency-version: 1.1.23
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-dropdown-menu"
  dependency-version: 2.1.24
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-label"
  dependency-version: 2.1.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-popover"
  dependency-version: 1.1.23
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-progress"
  dependency-version: 1.1.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-radio-group"
  dependency-version: 1.4.7
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-scroll-area"
  dependency-version: 1.2.18
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-select"
  dependency-version: 2.3.7
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-separator"
  dependency-version: 1.1.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-slot"
  dependency-version: 1.3.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-switch"
  dependency-version: 1.3.7
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-tabs"
  dependency-version: 1.1.21
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-toast"
  dependency-version: 1.2.23
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@radix-ui/react-tooltip"
  dependency-version: 1.2.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@react-email/render"
  dependency-version: 2.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@react-email/ui"
  dependency-version: 6.9.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@simplewebauthn/server"
  dependency-version: 13.3.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@tailwindcss/postcss"
  dependency-version: 4.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.102.8
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@tanstack/react-query-devtools"
  dependency-version: 5.102.8
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@tanstack/react-table"
  dependency-version: 9.2.4
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: npm-dependencies
- dependency-name: "@types/node"
  dependency-version: 26.4.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: npm-dependencies
- dependency-name: "@types/nodemailer"
  dependency-version: 8.0.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@types/pg"
  dependency-version: 8.23.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@types/pg"
  dependency-version: 8.23.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@types/react"
  dependency-version: 19.2.18
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@types/react"
  dependency-version: 19.2.18
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@types/react-dom"
  dependency-version: 19.2.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@types/react-dom"
  dependency-version: 19.2.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@types/semver"
  dependency-version: 7.8.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@types/semver"
  dependency-version: 7.8.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@types/sshpk"
  dependency-version: 1.17.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: axios
  dependency-version: 1.20.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: esbuild
  dependency-version: 0.28.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: esbuild-node-externals
  dependency-version: 2.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: npm-dependencies
- dependency-name: eslint
  dependency-version: 10.9.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: eslint-config-next
  dependency-version: 16.3.3
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: express-rate-limit
  dependency-version: 8.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: gpt-tokenizer
  dependency-version: 4.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: npm-dependencies
- dependency-name: helmet
  dependency-version: 8.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: input-otp
  dependency-version: 1.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: ioredis
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: npm-dependencies
- dependency-name: js-yaml
  dependency-version: 5.4.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: npm-dependencies
- dependency-name: lucide-react
  dependency-version: 1.35.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: maxmind
  dependency-version: 5.0.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: next
  dependency-version: 16.3.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: next-intl
  dependency-version: 4.14.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: nodemailer
  dependency-version: 9.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: pg
  dependency-version: 8.23.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: postcss
  dependency-version: 8.5.26
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: posthog-node
  dependency-version: 5.51.4
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: prettier
  dependency-version: 3.9.6
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: react
  dependency-version: 19.2.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: react-day-picker
  dependency-version: 10.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: npm-dependencies
- dependency-name: react-dom
  dependency-version: 19.2.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: react-email
  dependency-version: 6.9.3
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: react-hook-form
  dependency-version: 7.86.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: react-icons
  dependency-version: 5.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: recharts
  dependency-version: 3.10.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: semver
  dependency-version: 7.8.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: stripe
  dependency-version: 22.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: tailwindcss
  dependency-version: 4.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: tsc-alias
  dependency-version: 1.9.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: tsx
  dependency-version: 4.23.12
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: typescript
  dependency-version: 7.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: npm-dependencies
- dependency-name: typescript-eslint
  dependency-version: 8.68.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: uuid
  dependency-version: 14.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: ws
  dependency-version: 8.21.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: yargs
  dependency-version: 18.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: zod
  dependency-version: 4.5.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-03 14:58:30 +00:00
Owen Schwartz ce53b8c73d Merge pull request #3687 from fosrl/dependabot/npm_and_yarn/browserslist-4.28.8
Bump browserslist from 4.28.2 to 4.28.8
2026-09-03 10:52:28 -04:00
Owen Schwartz 6ff2b3e2fe Merge pull request #3688 from fosrl/crowdin_dev
New Crowdin updates
2026-09-03 10:52:08 -04:00
Owen 4dada38cb0 Please eslint 2026-09-03 10:51:46 -04:00
Owen Schwartz 732ea034f8 New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-09-03 10:48:02 -04:00
Owen Schwartz 581137c486 New translations en-us.json (Chinese Simplified)
[ci skip]
2026-09-03 10:48:00 -04:00
Owen Schwartz d75c6da3be New translations en-us.json (Turkish)
[ci skip]
2026-09-03 10:47:57 -04:00
Owen Schwartz 11b51202b8 New translations en-us.json (Russian)
[ci skip]
2026-09-03 10:47:55 -04:00
Owen Schwartz c35de18b9b New translations en-us.json (Portuguese)
[ci skip]
2026-09-03 10:47:53 -04:00
Owen Schwartz 353273a9f2 New translations en-us.json (Polish)
[ci skip]
2026-09-03 10:47:50 -04:00
Owen Schwartz 7c20a292e1 New translations en-us.json (Dutch)
[ci skip]
2026-09-03 10:47:48 -04:00
Owen Schwartz 6c13d6e343 New translations en-us.json (Korean)
[ci skip]
2026-09-03 10:47:46 -04:00
Owen Schwartz e641a8ce1b New translations en-us.json (Italian)
[ci skip]
2026-09-03 10:47:44 -04:00
Owen Schwartz a3419f60d6 New translations en-us.json (German)
[ci skip]
2026-09-03 10:47:41 -04:00
Owen Schwartz 2db3051a1a New translations en-us.json (Danish)
[ci skip]
2026-09-03 10:47:39 -04:00
Owen Schwartz 3c49b6f3d6 New translations en-us.json (Czech)
[ci skip]
2026-09-03 10:47:37 -04:00
Owen Schwartz 9ad4e36fa9 New translations en-us.json (Bulgarian)
[ci skip]
2026-09-03 10:47:34 -04:00
Owen Schwartz c489ed5724 New translations en-us.json (Spanish)
[ci skip]
2026-09-03 10:47:32 -04:00
Owen Schwartz fca3a1e5fa New translations en-us.json (French)
[ci skip]
2026-09-03 10:47:30 -04:00
dependabot[bot] b29348d004 Bump browserslist from 4.28.2 to 4.28.8
Bumps [browserslist](https://github.com/browserslist/browserslist) from 4.28.2 to 4.28.8.
- [Release notes](https://github.com/browserslist/browserslist/releases)
- [Changelog](https://github.com/browserslist/browserslist/blob/main/CHANGELOG.md)
- [Commits](https://github.com/browserslist/browserslist/compare/4.28.2...4.28.8)

---
updated-dependencies:
- dependency-name: browserslist
  dependency-version: 4.28.8
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-03 14:47:29 +00:00
Owen Schwartz 337d9a33a3 Merge pull request #3684 from fosrl/dependabot/npm_and_yarn/qs-6.16.0
Bump qs from 6.15.2 to 6.16.0
2026-09-03 10:46:26 -04:00
Owen Schwartz c61e96b1f0 Merge pull request #3682 from fosrl/dependabot/npm_and_yarn/fast-uri-3.1.7
Bump fast-uri from 3.1.5 to 3.1.7
2026-09-03 10:46:16 -04:00
dependabot[bot] 35bc692892 Bump qs from 6.15.2 to 6.16.0
Bumps [qs](https://github.com/ljharb/qs) from 6.15.2 to 6.16.0.
- [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/qs/compare/v6.15.2...v6.16.0)

---
updated-dependencies:
- dependency-name: qs
  dependency-version: 6.16.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-03 06:50:13 +00:00
dependabot[bot] 714e7e0ba4 Bump fast-uri from 3.1.5 to 3.1.7
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.5 to 3.1.7.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.5...v3.1.7)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-03 01:36:44 +00:00
Owen 49d5b0ec34 Increase maxRetriesPerRequest for Redis connections to improve resilience 2026-09-02 11:57:50 -04:00
Owen e0937a3afa Add validation for health check hostname
Fixes #3677
2026-09-02 10:42:59 -04:00
Owen 8d7e73afa8 Merge branch 'dev' of github.com:fosrl/pangolin into dev 2026-09-02 10:32:24 -04:00
Owen a5ce56ea89 Move the session logs to private where it should be 2026-09-01 17:56:36 -04:00
133 changed files with 6187 additions and 4342 deletions
+8
View File
@@ -34,6 +34,14 @@ body:
validations:
required: true
- type: textarea
attributes:
label: AI Disclosure
description: |
If you used AI to help write this issue, please disclose it here. This is important for transparency and helps maintain the integrity of the issue tracking process.
validations:
required: true
- type: textarea
attributes:
label: Expected Behavior
+16 -5
View File
@@ -37,11 +37,22 @@
<p align="center">
<strong>
Get started with Pangolin at <a href="https://app.pangolin.net/auth/signup">app.pangolin.net</a>
Get started with Pangolin Cloud at <a href="https://app.pangolin.net/auth/signup">app.pangolin.net</a>
</strong>
</p>
Pangolin is an open-source, identity-based remote access platform built on WireGuard® that enables secure connectivity to infrastructure anywhere. It combines reverse-proxy and VPN capabilities into one platform, providing browser-based access to web applications and client-based access to private resources with NAT traversal, all with granular access control.
Pangolin is an open-source SASE platform, built on WireGuard®, with a simple mission: connect and protect your users, wherever they are. It brings networking and security together as one system including a zero-trust VPN, zero-trust reverse proxy, privileged access management, and an identity-aware AI gateway, all sharing one identity and policy model. It's the same idea behind platforms like Cloudflare One, Zscaler, and Prisma but open, self-hostable, and built to stay light and easy to deploy.
### Networking and security that's unified, open, and simple
Legacy SASE platforms got the idea right: connectivity and security belong together. But they delivered it as a heavyweight, closed, cloud-locked stack assembled from years of patchwork. Pangolin exists to do that unification differently, in the open, self-hostable, and simple enough that administrators actually enjoy running it.
* **Open source, not a black box**: the code is open and auditable, so you can see exactly how your traffic is handled and how access decisions get made, instead of trusting a closed cloud control plane.
* **Networking and security as one platform**: sites, reverse proxy, client access, RBAC, and the AI gateway share one identity and policy model, so protecting users and connecting them are executed together.
* **Lightweight by design**: the whole platform is built to stay small and fast: easy to self-host on a small server, with a lightweight, user-space connector that goes in your private networks.
* **Enjoyable to use**: a clean, modern interface and a setup flow that gets out of your way, so managing access feels simple instead of like fighting a legacy admin console.
* **Zero trust from day one**: access is granted per resource, not per network, with identity provider integration, role-based access control, and full audit logging.
* **Run it your way**: self-host the Community Edition for free, step up to the Enterprise Edition for advanced features, or use Pangolin Cloud if you'd rather not manage infrastructure at all.
## Installation
@@ -53,9 +64,9 @@ Pangolin is an open-source, identity-based remote access platform built on WireG
## Deployment Options
- **Pangolin Cloud** - Fully managed service - no infrastructure required.
- **Self-Host: Community Edition** - Free, open source, and licensed under AGPL-3.
- **Self-Host: Enterprise Edition** - Licensed under Fossorial Commercial License. Free for personal and hobbyist use, and for businesses making less than \$100K USD gross annual revenue.
- **Pangolin Cloud** - Fully managed service with no infrastructure required.
- **Self-Host: Community Edition** - Free, open-source, and licensed under AGPL-3.
- **Self-Host: Enterprise Edition** - Open-core, and licensed under Fossorial Commercial License. Free for personal and hobbyist use, and for businesses making less than \$100K USD gross annual revenue.
## Key Features
+1 -1
View File
@@ -5,7 +5,7 @@ import { encrypt } from "@server/lib/crypto";
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
import { generateCA } from "@server/lib/sshCA";
import fs from "fs";
import yaml from "js-yaml";
import * as yaml from "js-yaml";
type GenerateOrgCaKeysArgs = {
orgId: string;
+1 -1
View File
@@ -4,7 +4,7 @@ import { encrypt, decrypt } from "@server/lib/crypto";
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
import { eq } from "drizzle-orm";
import fs from "fs";
import yaml from "js-yaml";
import * as yaml from "js-yaml";
type RotateServerSecretArgs = {
"old-secret": string;
+9 -1
View File
@@ -1176,6 +1176,10 @@
"idpJmespathAboutDescriptionLink": "Научете повече за JMESPath",
"idpJmespathLabel": "Идентификатор на пътя",
"idpJmespathLabelDescription": "Пътят към идентификатора на потребителя в ID токена",
"idpIdentifierChangeTitle": "Предупреждение за промяна на пътя на идентификатора",
"idpIdentifierChangeDescription": "Ще промените пътя на идентификатора. Това ще повлияе на начина, по който съществуващите потребители са разпределени. Потребители, които преди са влизали чрез този доставчик на идентичности, може вече да не бъдат разпознавани като същите потребители.",
"idpIdentifierChangeConfirmMessage": "Потвърждавам",
"idpIdentifierChangeWarningText": "Това ще повлияе на начина, по който съществуващите потребители са разпределени",
"idpJmespathEmailPathOptional": "Път за имейл (по избор)",
"idpJmespathEmailPathOptionalDescription": "Пътят до имейла на потребителя в ID токена",
"idpJmespathNamePathOptional": "Път (по избор) на име",
@@ -1573,6 +1577,8 @@
"search": "Търси…",
"searchPlaceholder": "Търсене...",
"emptySearchOptions": "Няма намерени опции",
"ipFilterSearchPlaceholder": "Въведете IP адрес…",
"ipFilterEmptyMessage": "Въведете IP адрес, за да филтрирате по него",
"create": "Създаване",
"orgs": "Организации",
"loginError": "Възникна неочаквана грешка. Моля, опитайте отново.",
@@ -2596,6 +2602,7 @@
"createDomainType": "Тип:",
"createDomainName": "Име:",
"createDomainValue": "Стойност:",
"multiSelectFilterCount": "{count} избрани",
"createDomainCnameRecords": "CNAME записи",
"createDomainARecords": "A записи",
"createDomainRecordNumber": "Запис {number}",
@@ -2710,6 +2717,7 @@
"healthScheme": "Метод",
"healthSelectScheme": "Избор на метод",
"healthCheckPortInvalid": "Портът трябва да бъде между 1 и 65535",
"healthCheckHostnameInvalid": "Името на хоста не трябва да съдържа празни символи",
"healthCheckPath": "Път",
"healthHostname": "IP / Хост",
"healthPort": "Порт",
@@ -2993,7 +3001,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "Добавете CIDR диапазон (напр. 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Неуспешно зареждане на подмрежи",
"remoteExitNodeNetworkingLabelsTitle": "Етикети за Предпочитания",
"remoteExitNodeNetworkingLabelsDescription": "Сайтове с тези етикети ще бъдат принудени да се свържат чрез този отдалечен край.",
"remoteExitNodeNetworkingLabelsDescription": "Сайтовете с тези етикети ще предпочетат да се свържат чрез този отдалечен изходен възел.",
"remoteExitNodeNetworkingLabelsButtonText": "Изберете етикети...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Търсене на етикети...",
"remoteExitNodeNetworkingLabelsLoadError": "Неуспешно зареждане на етикети",
+9 -1
View File
@@ -1176,6 +1176,10 @@
"idpJmespathAboutDescriptionLink": "Další informace o cestě JMESPath",
"idpJmespathLabel": "Cesta identifikátoru",
"idpJmespathLabelDescription": "Cesta k identifikátoru uživatele v tokenu ID",
"idpIdentifierChangeTitle": "Varování o změně cesty identifikátoru",
"idpIdentifierChangeDescription": "Chystáte se změnit cestu identifikátoru. Tímto se změní způsob mapování stávajících uživatelů. Uživatelé, kteří se dříve přihlásili přes tohoto poskytovatele identity, již nemusí být rozpoznáni jako stejní uživatelé.",
"idpIdentifierChangeConfirmMessage": "Potvrzuji",
"idpIdentifierChangeWarningText": "Toto ovlivní, jak budou mapováni stávající uživatelé",
"idpJmespathEmailPathOptional": "Cesta e-mailu (volitelné)",
"idpJmespathEmailPathOptionalDescription": "Cesta k e-mailu uživatele v ID tokenu",
"idpJmespathNamePathOptional": "Cesta k názvu (volitelné)",
@@ -1573,6 +1577,8 @@
"search": "Vyhledávání…",
"searchPlaceholder": "Hledat...",
"emptySearchOptions": "Nebyly nalezeny žádné možnosti",
"ipFilterSearchPlaceholder": "Zadejte IP adresu…",
"ipFilterEmptyMessage": "Zadejte IP adresu pro filtrování",
"create": "Vytvořit",
"orgs": "Organizace",
"loginError": "Došlo k neočekávané chybě. Zkuste to prosím znovu.",
@@ -2596,6 +2602,7 @@
"createDomainType": "Typ:",
"createDomainName": "Jméno:",
"createDomainValue": "Hodnota:",
"multiSelectFilterCount": "{count} vybráno",
"createDomainCnameRecords": "Záznamy CNAME",
"createDomainARecords": "Záznamy",
"createDomainRecordNumber": "Nahrát {number}",
@@ -2710,6 +2717,7 @@
"healthScheme": "Způsob",
"healthSelectScheme": "Vybrat metodu",
"healthCheckPortInvalid": "Port musí být mezi 1 a 65535",
"healthCheckHostnameInvalid": "Název hostitele nesmí obsahovat mezery",
"healthCheckPath": "Cesta",
"healthHostname": "IP / Hostitel",
"healthPort": "Přístav",
@@ -2993,7 +3001,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "Přidejte rozsah CIDR (např. 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Nepodařilo se načíst podsítě",
"remoteExitNodeNetworkingLabelsTitle": "Názvy preferencí",
"remoteExitNodeNetworkingLabelsDescription": "Weby s těmito názvy budou nucenyipojit se tímto vzdáleným výstupním uzlem.",
"remoteExitNodeNetworkingLabelsDescription": "Stránky s těmito štítky preferují spojeníes tento vzdálený výstupní uzel.",
"remoteExitNodeNetworkingLabelsButtonText": "Vyberte názvy...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Hledat názvy...",
"remoteExitNodeNetworkingLabelsLoadError": "Nepodařilo se načíst názvy",
+9 -1
View File
@@ -1176,6 +1176,10 @@
"idpJmespathAboutDescriptionLink": "Læs mere om JMESPath",
"idpJmespathLabel": "Identifikatorsti",
"idpJmespathLabelDescription": "Stien til brugeridentifikatoren i ID-tokenet",
"idpIdentifierChangeTitle": "Advarsel om ændring af identifikatorsti",
"idpIdentifierChangeDescription": "Du er ved at ændre identifikatorstien. Dette vil påvirke, hvordan eksisterende brugere bliver kortlagt. Brugere, der tidligere har logget ind gennem denne identitetsudbyder, genkendes muligvis ikke længere som de samme brugere.",
"idpIdentifierChangeConfirmMessage": "Jeg bekræfter",
"idpIdentifierChangeWarningText": "Dette vil påvirke, hvordan eksisterende brugere bliver kortlagt",
"idpJmespathEmailPathOptional": "E-mailsti (Valgfrit)",
"idpJmespathEmailPathOptionalDescription": "Stien til brugerens e-mailadresse i ID-tokenet",
"idpJmespathNamePathOptional": "Navn Sti (Valgfrit)",
@@ -1573,6 +1577,8 @@
"search": "Søg…",
"searchPlaceholder": "Søg...",
"emptySearchOptions": "Ingen valg fundet",
"ipFilterSearchPlaceholder": "Indtast en IP-adresse…",
"ipFilterEmptyMessage": "Indtast en IP-adresse for at filtrere efter",
"create": "Opret",
"orgs": "Organisationer",
"loginError": "Der opstod en uventet fejl. Prøv venligst igen.",
@@ -2596,6 +2602,7 @@
"createDomainType": "Type:",
"createDomainName": "Navn:",
"createDomainValue": "Værdi:",
"multiSelectFilterCount": "{count} valgt",
"createDomainCnameRecords": "CNAME-poster",
"createDomainARecords": "A-poster",
"createDomainRecordNumber": "Post {number}",
@@ -2710,6 +2717,7 @@
"healthScheme": "Metode",
"healthSelectScheme": "Vælg metode",
"healthCheckPortInvalid": "Porten skal være mellem 1 og 65535",
"healthCheckHostnameInvalid": "Værtsnavnet må ikke indeholde mellemrum",
"healthCheckPath": "Sti",
"healthHostname": "IP / Vært",
"healthPort": "Port",
@@ -2993,7 +3001,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "Tilføj et CIDR-område (f.eks. 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Kunne ikke indlæse subnets",
"remoteExitNodeNetworkingLabelsTitle": "Præference Etiketter",
"remoteExitNodeNetworkingLabelsDescription": "Sites med disse etiketter vil blive tvunget til at oprette forbindelse gennem denne fjerne exit-node.",
"remoteExitNodeNetworkingLabelsDescription": "Sider med disse etiketter vil foretrække at forbinde gennem denne fjernudgarnknude.",
"remoteExitNodeNetworkingLabelsButtonText": "Vælg etiketter...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Søg efter etiketter...",
"remoteExitNodeNetworkingLabelsLoadError": "Kunne ikke indlæse etiketter",
+9 -1
View File
@@ -1176,6 +1176,10 @@
"idpJmespathAboutDescriptionLink": "Mehr über JMESPath erfahren",
"idpJmespathLabel": "Identifikationspfad",
"idpJmespathLabelDescription": "Der JMESPath zum Benutzeridentifikator im ID-Token",
"idpIdentifierChangeTitle": "Warnung zur Änderung des Identifikatorpfads",
"idpIdentifierChangeDescription": "Sie stehen kurz davor, den Identifikatorpfad zu ändern. Dies wird beeinflussen, wie bestehende Benutzer zugeordnet werden. Benutzer, die sich zuvor über diesen Identitätsanbieter angemeldet haben, werden möglicherweise nicht mehr als dieselben Benutzer erkannt.",
"idpIdentifierChangeConfirmMessage": "Ich bestätige",
"idpIdentifierChangeWarningText": "Dies wird beeinflussen, wie bestehende Benutzer zugeordnet werden",
"idpJmespathEmailPathOptional": "E-Mail-Pfad (Optional)",
"idpJmespathEmailPathOptionalDescription": "Der JMESPath zur E-Mail-Adresse des Benutzers im ID-Token",
"idpJmespathNamePathOptional": "Namenspfad (Optional)",
@@ -1573,6 +1577,8 @@
"search": "Suche…",
"searchPlaceholder": "Suche...",
"emptySearchOptions": "Keine Optionen gefunden",
"ipFilterSearchPlaceholder": "Geben Sie eine IP-Adresse ein…",
"ipFilterEmptyMessage": "Geben Sie eine IP-Adresse zur Filterung ein",
"create": "Erstellen",
"orgs": "Organisationen",
"loginError": "Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es erneut.",
@@ -2596,6 +2602,7 @@
"createDomainType": "Typ:",
"createDomainName": "Name:",
"createDomainValue": "Wert:",
"multiSelectFilterCount": "{count} ausgewählt",
"createDomainCnameRecords": "CNAME-Einträge",
"createDomainARecords": "A-Aufzeichnungen",
"createDomainRecordNumber": "Eintrag {number}",
@@ -2710,6 +2717,7 @@
"healthScheme": "Methode",
"healthSelectScheme": "Methode auswählen",
"healthCheckPortInvalid": "Der Port muss zwischen 1 und 65535 liegen",
"healthCheckHostnameInvalid": "Der Hostname darf keinen Leerraum enthalten",
"healthCheckPath": "Pfad",
"healthHostname": "IP / Host",
"healthPort": "Port",
@@ -2993,7 +3001,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "Fügen Sie einen CIDR-Bereich hinzu (z.B. 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Fehler beim Laden der Subnetze",
"remoteExitNodeNetworkingLabelsTitle": "Präferenzetiketten",
"remoteExitNodeNetworkingLabelsDescription": "Standorte mit diesen Etiketten werden gezwungen, über diesen Remote Exit Node zu verbinden.",
"remoteExitNodeNetworkingLabelsDescription": "Standorte mit diesen Labels bevorzugen die Verbindung über diesen Remote-Exit-Knoten.",
"remoteExitNodeNetworkingLabelsButtonText": "Etiketten auswählen...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Etiketten suchen...",
"remoteExitNodeNetworkingLabelsLoadError": "Fehler beim Laden der Etiketten",
+3 -19
View File
@@ -1424,24 +1424,6 @@
"logoutError": "Error logging out",
"signingAs": "Signed in as",
"serverAdmin": "Server Admin",
"promoteServerAdmin": "Promote to Server admin",
"promoteServerAdminTitle": "Promote to Server Admin",
"promoteServerAdminQuestion": "Are you sure you want to promote {selectedUser} to server admin?",
"promoteServerAdminMessage": "Server admins have full access to every organization, user, and setting on this instance.",
"promoteServerAdminWarning": "This can be undone at any time by demoting the user from this page.",
"promoteServerAdminConfirm": "Promote to server admin",
"promoteServerAdminSuccess": "User promoted",
"promoteServerAdminSuccessDescription": "{selectedUser} is now a server admin.",
"promoteServerAdminError": "Failed to promote user",
"demoteServerAdmin": "Demote from Server admin",
"demoteServerAdminTitle": "Demote from Server Admin",
"demoteServerAdminQuestion": "Are you sure you want to demote {selectedUser} from server admin?",
"demoteServerAdminMessage": "{selectedUser} will lose full access to every organization, user, and setting on this instance.",
"demoteServerAdminWarning": "This can be undone at any time by promoting the user from this page.",
"demoteServerAdminConfirm": "Demote from server admin",
"demoteServerAdminSuccess": "User demoted",
"demoteServerAdminSuccessDescription": "{selectedUser} is no longer a server admin.",
"demoteServerAdminError": "Failed to demote user",
"managedSelfhosted": "Managed Self-Hosted",
"otpEnable": "Enable Two-factor",
"otpDisable": "Disable Two-factor",
@@ -2735,6 +2717,7 @@
"healthScheme": "Method",
"healthSelectScheme": "Select Method",
"healthCheckPortInvalid": "Port must be between 1 and 65535",
"healthCheckHostnameInvalid": "Hostname must not contain whitespace",
"healthCheckPath": "Path",
"healthHostname": "IP / Host",
"healthPort": "Port",
@@ -3494,7 +3477,8 @@
},
"priority": "Priority",
"priorityDescription": "Higher priority routes are evaluated first. Priority = 100 means automatic ordering (system decides). Use another number to enforce manual priority.",
"instanceName": "Instance Name",
"instanceName": "Server ID",
"clearInstanceName": "Reset Server Association",
"pathMatchModalTitle": "Configure Path Matching",
"pathMatchModalDescription": "Set up how incoming requests should be matched based on their path.",
"pathMatchType": "Match Type",
+9 -1
View File
@@ -1176,6 +1176,10 @@
"idpJmespathAboutDescriptionLink": "Más información sobre JMESPath",
"idpJmespathLabel": "Ruta del identificador",
"idpJmespathLabelDescription": "La ruta al identificador de usuario en el token de ID",
"idpIdentifierChangeTitle": "Advertencia de Cambio de Ruta de Identificador",
"idpIdentifierChangeDescription": "Está a punto de cambiar la ruta del identificador. Esto afectará cómo se asignan los usuarios existentes. Los usuarios que anteriormente iniciaron sesión a través de este proveedor de identidad pueden ya no ser reconocidos como los mismos usuarios.",
"idpIdentifierChangeConfirmMessage": "Confirmo",
"idpIdentifierChangeWarningText": "Esto afectará cómo se asignan los usuarios existentes",
"idpJmespathEmailPathOptional": "Ruta de correo (opcional)",
"idpJmespathEmailPathOptionalDescription": "La ruta al correo electrónico del usuario en el token de ID",
"idpJmespathNamePathOptional": "Ruta del nombre (opcional)",
@@ -1573,6 +1577,8 @@
"search": "Buscar…",
"searchPlaceholder": "Buscar...",
"emptySearchOptions": "No se encontraron opciones",
"ipFilterSearchPlaceholder": "Introduzca una dirección IP…",
"ipFilterEmptyMessage": "Introduzca una dirección IP para filtrar por",
"create": "Crear",
"orgs": "Organizaciones",
"loginError": "Ocurrió un error inesperado. Por favor, inténtelo de nuevo.",
@@ -2596,6 +2602,7 @@
"createDomainType": "Tipo:",
"createDomainName": "Nombre:",
"createDomainValue": "Valor:",
"multiSelectFilterCount": "{count} seleccionado",
"createDomainCnameRecords": "Registros CNAME",
"createDomainARecords": "Registros A",
"createDomainRecordNumber": "Registro {number}",
@@ -2710,6 +2717,7 @@
"healthScheme": "Método",
"healthSelectScheme": "Seleccionar método",
"healthCheckPortInvalid": "El puerto debe estar entre 1 y 65535",
"healthCheckHostnameInvalid": "El nombre de host no debe contener espacios en blanco",
"healthCheckPath": "Ruta",
"healthHostname": "IP / Nombre del host",
"healthPort": "Puerto",
@@ -2993,7 +3001,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "Añadir un rango CIDR (e.g. 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Error al cargar las subredes",
"remoteExitNodeNetworkingLabelsTitle": "Etiquetas de Preferencias",
"remoteExitNodeNetworkingLabelsDescription": "Los sitios con estas etiquetas se verán obligados a conectarse a través de este nodo de salida remoto.",
"remoteExitNodeNetworkingLabelsDescription": "Los sitios con estas etiquetas preferirán conectarse a través de este nodo de salida remoto.",
"remoteExitNodeNetworkingLabelsButtonText": "Seleccionar etiquetas...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Buscar etiquetas...",
"remoteExitNodeNetworkingLabelsLoadError": "Error al cargar las etiquetas",
+9 -1
View File
@@ -1176,6 +1176,10 @@
"idpJmespathAboutDescriptionLink": "En savoir plus sur JMESPath",
"idpJmespathLabel": "Chemin d'identification",
"idpJmespathLabelDescription": "Le JMESPath vers l'identifiant de l'utilisateur dans le jeton ID",
"idpIdentifierChangeTitle": "Avertissement de changement de chemin d'identification",
"idpIdentifierChangeDescription": "Vous êtes sur le point de modifier le chemin d'identification. Cela affectera la manière dont les utilisateurs existants sont mappés. Les utilisateurs qui se sont connectés via ce fournisseur d'identité peuvent ne plus être reconnus comme les mêmes utilisateurs.",
"idpIdentifierChangeConfirmMessage": "Je confirme",
"idpIdentifierChangeWarningText": "Cela affectera la manière dont les utilisateurs existants sont mappés",
"idpJmespathEmailPathOptional": "Chemin de l'email (Optionnel)",
"idpJmespathEmailPathOptionalDescription": "Le JMESPath vers l'email de l'utilisateur dans le jeton ID",
"idpJmespathNamePathOptional": "Chemin du nom (Optionnel)",
@@ -1573,6 +1577,8 @@
"search": "Rechercher…",
"searchPlaceholder": "Recherche...",
"emptySearchOptions": "Aucune option trouvée",
"ipFilterSearchPlaceholder": "Entrez une adresse IP…",
"ipFilterEmptyMessage": "Entrez une adresse IP pour filtrer",
"create": "Créer",
"orgs": "Organisations",
"loginError": "Une erreur inattendue s'est produite. Veuillez réessayer.",
@@ -2596,6 +2602,7 @@
"createDomainType": "Type :",
"createDomainName": "Nom :",
"createDomainValue": "Valeur :",
"multiSelectFilterCount": "{count} sélectionné",
"createDomainCnameRecords": "Enregistrements CNAME",
"createDomainARecords": "Enregistrements A",
"createDomainRecordNumber": "Enregistrement {number}",
@@ -2710,6 +2717,7 @@
"healthScheme": "Méthode",
"healthSelectScheme": "Sélectionnez la méthode",
"healthCheckPortInvalid": "Le port doit être compris entre 1 et 65535",
"healthCheckHostnameInvalid": "Le nom d'hôte ne doit pas contenir d'espaces blancs",
"healthCheckPath": "Chemin d'accès",
"healthHostname": "IP / Hôte",
"healthPort": "Port",
@@ -2993,7 +3001,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "Ajouter une plage CIDR (par exemple 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Échec du chargement des sous-réseaux",
"remoteExitNodeNetworkingLabelsTitle": "Étiquettes de préférences",
"remoteExitNodeNetworkingLabelsDescription": "Les sites avec ces étiquettes devront se connecter via ce nœud de sortie distant.",
"remoteExitNodeNetworkingLabelsDescription": "Les sites dotés de ces étiquettes préféreront se connecter via ce nœud de sortie distant.",
"remoteExitNodeNetworkingLabelsButtonText": "Sélectionner des étiquettes...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Chercher des étiquettes...",
"remoteExitNodeNetworkingLabelsLoadError": "Échec du chargement des étiquettes",
+9 -1
View File
@@ -1176,6 +1176,10 @@
"idpJmespathAboutDescriptionLink": "Scopri di più su JMESPath",
"idpJmespathLabel": "Percorso Identificativo",
"idpJmespathLabelDescription": "Il JMESPath per l'identificatore dell'utente nel token ID",
"idpIdentifierChangeTitle": "Avviso di cambio percorso identificatore",
"idpIdentifierChangeDescription": "Stai per cambiare il percorso identificativo. Questo influirà su come gli utenti esistenti sono mappati. Gli utenti che in precedenza hanno effettuato l'accesso attraverso questo provider di identità potrebbero non essere più riconosciuti come gli stessi utenti.",
"idpIdentifierChangeConfirmMessage": "Confermo",
"idpIdentifierChangeWarningText": "Questo influirà su come gli utenti esistenti sono mappati",
"idpJmespathEmailPathOptional": "Percorso Email (Opzionale)",
"idpJmespathEmailPathOptionalDescription": "Il JMESPath per l'email dell'utente nel token ID",
"idpJmespathNamePathOptional": "Percorso Nome (Opzionale)",
@@ -1573,6 +1577,8 @@
"search": "Cerca…",
"searchPlaceholder": "Cerca...",
"emptySearchOptions": "Nessuna opzione trovata",
"ipFilterSearchPlaceholder": "Inserisci un indirizzo IP…",
"ipFilterEmptyMessage": "Inserisci un indirizzo IP per filtrare",
"create": "Crea",
"orgs": "Organizzazioni",
"loginError": "Si è verificato un errore imprevisto. Riprova.",
@@ -2596,6 +2602,7 @@
"createDomainType": "Tipo:",
"createDomainName": "Nome:",
"createDomainValue": "Valore:",
"multiSelectFilterCount": "{count} selezionato",
"createDomainCnameRecords": "Record CNAME",
"createDomainARecords": "Record A",
"createDomainRecordNumber": "Record {number}",
@@ -2710,6 +2717,7 @@
"healthScheme": "Metodo",
"healthSelectScheme": "Seleziona Metodo",
"healthCheckPortInvalid": "La porta deve essere compresa tra 1 e 65535",
"healthCheckHostnameInvalid": "Il nome dell'host non deve contenere spazi",
"healthCheckPath": "Percorso",
"healthHostname": "IP / Nome host",
"healthPort": "Porta",
@@ -2993,7 +3001,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "Aggiungi un intervallo CIDR (ad esempio 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Caricamento sottoreti fallito",
"remoteExitNodeNetworkingLabelsTitle": "Etichette Preferenze",
"remoteExitNodeNetworkingLabelsDescription": "I siti con queste etichette saranno collegati attraverso questo nodo di uscita remoto.",
"remoteExitNodeNetworkingLabelsDescription": "I siti con queste etichette preferiranno connettersi tramite questo nodo di uscita remoto.",
"remoteExitNodeNetworkingLabelsButtonText": "Seleziona etichette...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Cerca etichette...",
"remoteExitNodeNetworkingLabelsLoadError": "Caricamento etichette fallito",
+9 -1
View File
@@ -1176,6 +1176,10 @@
"idpJmespathAboutDescriptionLink": "JMESPath에 대해 더 알아보기",
"idpJmespathLabel": "식별자 경로",
"idpJmespathLabelDescription": "ID 토큰에서 사용자 식별자에 대한 경로",
"idpIdentifierChangeTitle": "식별자 경로 변경 경고",
"idpIdentifierChangeDescription": "식별자 경로를 변경하려고 합니다. 이는 기존 사용자의 매핑 방법에 영향을 미칩니다. 이전에 이 ID 공급자를 통해 로그인한 사용자는 더 이상 동일한 사용자로 인식되지 않을 수 있습니다. ",
"idpIdentifierChangeConfirmMessage": "확인합니다",
"idpIdentifierChangeWarningText": "이는 기존 사용자의 매핑 방법에 영향을 미칩니다",
"idpJmespathEmailPathOptional": "이메일 경로 (선택 사항)",
"idpJmespathEmailPathOptionalDescription": "ID 토큰에서 사용자의 이메일 경로",
"idpJmespathNamePathOptional": "이름 경로 (선택 사항)",
@@ -1573,6 +1577,8 @@
"search": "검색…",
"searchPlaceholder": "검색...",
"emptySearchOptions": "옵션이 없습니다",
"ipFilterSearchPlaceholder": "IP 주소를 입력하세요…",
"ipFilterEmptyMessage": "필터링할 IP 주소를 입력하세요",
"create": "생성",
"orgs": "조직",
"loginError": "예기치 않은 오류가 발생했습니다. 다시 시도해주세요.",
@@ -2596,6 +2602,7 @@
"createDomainType": "유형:",
"createDomainName": "이름:",
"createDomainValue": "값:",
"multiSelectFilterCount": "{count} 선택됨",
"createDomainCnameRecords": "CNAME 레코드",
"createDomainARecords": "A 레코드",
"createDomainRecordNumber": "레코드 {number}",
@@ -2710,6 +2717,7 @@
"healthScheme": "방법",
"healthSelectScheme": "방법 선택",
"healthCheckPortInvalid": "포트는 1에서 65535 사이여야 합니다",
"healthCheckHostnameInvalid": "호스트 이름에는 공백이 포함될 수 없습니다",
"healthCheckPath": "경로",
"healthHostname": "IP / 호스트",
"healthPort": "포트",
@@ -2993,7 +3001,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "CIDR 범위 추가 (예: 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "서브넷 로드 실패",
"remoteExitNodeNetworkingLabelsTitle": "우선순위 레이블",
"remoteExitNodeNetworkingLabelsDescription": "이 레이블이 있는 사이트는 이 원격 출구 노드를 통해 연결니다.",
"remoteExitNodeNetworkingLabelsDescription": "이 레이블이 있는 사이트는 이 원격 종료 노드를 통해 연결하는 것을 선호합니다.",
"remoteExitNodeNetworkingLabelsButtonText": "레이블 선택...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "레이블 검색...",
"remoteExitNodeNetworkingLabelsLoadError": "레이블 로드 실패",
+9 -1
View File
@@ -1176,6 +1176,10 @@
"idpJmespathAboutDescriptionLink": "Lær mer om JMESPath",
"idpJmespathLabel": "Identifikatorsti",
"idpJmespathLabelDescription": "Stien til brukeridentifikatoren i ID-tokenet",
"idpIdentifierChangeTitle": "Advarsel om identifikatorbanendring",
"idpIdentifierChangeDescription": "Du er i ferd med å endre identifikatorbanen. Dette vil påvirke hvordan eksisterende brukere kartlegges. Brukere som tidligere logget inn gjennom denne identitetsleverandøren kan ikke lenger bli gjenkjent som de samme brukerne.",
"idpIdentifierChangeConfirmMessage": "Jeg bekrefter",
"idpIdentifierChangeWarningText": "Dette vil påvirke hvordan eksisterende brukere kartlegges",
"idpJmespathEmailPathOptional": "E-poststi (Valgfritt)",
"idpJmespathEmailPathOptionalDescription": "Stien til brukerens e-postadresse i ID-tokenet",
"idpJmespathNamePathOptional": "Navn Sti (Valgfritt)",
@@ -1573,6 +1577,8 @@
"search": "Søk…",
"searchPlaceholder": "Søk...",
"emptySearchOptions": "Ingen valg funnet",
"ipFilterSearchPlaceholder": "Angi en IP-adresse…",
"ipFilterEmptyMessage": "Angi en IP-adresse å filtrere etter",
"create": "Opprett",
"orgs": "Organisasjoner",
"loginError": "En uventet feil oppstod. Vennligst prøv igjen.",
@@ -2596,6 +2602,7 @@
"createDomainType": "Type:",
"createDomainName": "Navn:",
"createDomainValue": "Verdi:",
"multiSelectFilterCount": "{count} valgt",
"createDomainCnameRecords": "CNAME-oppføringer",
"createDomainARecords": "A-oppføringer",
"createDomainRecordNumber": "Oppføring {number}",
@@ -2710,6 +2717,7 @@
"healthScheme": "Metode",
"healthSelectScheme": "Velg metode",
"healthCheckPortInvalid": "Porten må være mellom 1 og 65535",
"healthCheckHostnameInvalid": "Vertsnavnet må ikke inneholde mellomrom",
"healthCheckPath": "Sti",
"healthHostname": "IP / Vert",
"healthPort": "Port",
@@ -2993,7 +3001,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "Legg til et CIDR-område (f.eks. 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Feil ved lasting av subnett",
"remoteExitNodeNetworkingLabelsTitle": "Preferanseetiketter",
"remoteExitNodeNetworkingLabelsDescription": "Områder med disse etikettene vil bli tvunget til å koble gjennom denne fjerne utgangsnoden.",
"remoteExitNodeNetworkingLabelsDescription": "Nettsteder med disse etikettene vil foretrekke å koble til gjennom denne eksterne utgangsnoden.",
"remoteExitNodeNetworkingLabelsButtonText": "Velg etiketter...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Søk etiketter...",
"remoteExitNodeNetworkingLabelsLoadError": "Feil ved lasting av etiketter",
+9 -1
View File
@@ -1176,6 +1176,10 @@
"idpJmespathAboutDescriptionLink": "Meer informatie over JMESPath",
"idpJmespathLabel": "ID pad",
"idpJmespathLabelDescription": "Het pad naar het gebruiker-id in het ID-token",
"idpIdentifierChangeTitle": "Waarschuwing voor Wijziging van ID-pad",
"idpIdentifierChangeDescription": "U staat op het punt om het ID-pad te wijzigen. Dit zal invloed hebben op hoe bestaande gebruikers worden gemapt. Gebruikers die voorheen via deze identiteitsprovider inlogden, worden mogelijk niet meer als dezelfde gebruikers herkend.",
"idpIdentifierChangeConfirmMessage": "Ik bevestig",
"idpIdentifierChangeWarningText": "Dit beïnvloedt hoe bestaande gebruikers worden gemapt",
"idpJmespathEmailPathOptional": "E-mail pad (optioneel)",
"idpJmespathEmailPathOptionalDescription": "Het pad naar het e-mailadres van de gebruiker in het ID-token",
"idpJmespathNamePathOptional": "Naam pad (optioneel)",
@@ -1573,6 +1577,8 @@
"search": "Zoeken…",
"searchPlaceholder": "Zoeken...",
"emptySearchOptions": "Geen opties gevonden",
"ipFilterSearchPlaceholder": "Voer een IP-adres in…",
"ipFilterEmptyMessage": "Voer een IP-adres in om op te filteren",
"create": "Aanmaken",
"orgs": "Organisaties",
"loginError": "Er is een onverwachte fout opgetreden. Probeer het opnieuw.",
@@ -2596,6 +2602,7 @@
"createDomainType": "Type:",
"createDomainName": "Naam:",
"createDomainValue": "Waarde:",
"multiSelectFilterCount": "{count} geselecteerd",
"createDomainCnameRecords": "CNAME-records",
"createDomainARecords": "A Records",
"createDomainRecordNumber": "Record {number}",
@@ -2710,6 +2717,7 @@
"healthScheme": "Methode",
"healthSelectScheme": "Selecteer methode",
"healthCheckPortInvalid": "Poort moet tussen 1 en 65535 zijn",
"healthCheckHostnameInvalid": "De hostnaam mag geen witruimtes bevatten",
"healthCheckPath": "Pad",
"healthHostname": "IP / Hostnaam",
"healthPort": "Poort",
@@ -2993,7 +3001,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "Voeg een CIDR-bereik toe (bijv. 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Kon subnets niet laden",
"remoteExitNodeNetworkingLabelsTitle": "Voorkeurslabels",
"remoteExitNodeNetworkingLabelsDescription": "Sites met deze labels worden verplicht om verbinding te maken via dit externe exit-knooppunt.",
"remoteExitNodeNetworkingLabelsDescription": "Sites met deze labels zullen bij voorkeur verbinding maken via deze externe exitnode.",
"remoteExitNodeNetworkingLabelsButtonText": "Selecteer labels...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Labels zoeken...",
"remoteExitNodeNetworkingLabelsLoadError": "Kon labels niet laden",
+9 -1
View File
@@ -1176,6 +1176,10 @@
"idpJmespathAboutDescriptionLink": "Dowiedz się więcej o JMESPath",
"idpJmespathLabel": "Ścieżka identyfikatora",
"idpJmespathLabelDescription": "JMESPath do identyfikatora użytkownika w tokenie ID",
"idpIdentifierChangeTitle": "Ostrzeżenie o zmianie ścieżki identyfikatora",
"idpIdentifierChangeDescription": "Zaraz zmienisz ścieżkę identyfikatora. To wpłynie na sposób mapowania istniejących użytkowników. Użytkownicy, którzy wcześniej logowali się przez tego dostawcę tożsamości, mogą nie być już rozpoznawani jako ci sami użytkownicy.",
"idpIdentifierChangeConfirmMessage": "Potwierdzam",
"idpIdentifierChangeWarningText": "To wpłynie na sposób mapowania istniejących użytkowników",
"idpJmespathEmailPathOptional": "Ścieżka email (Opcjonalnie)",
"idpJmespathEmailPathOptionalDescription": "JMESPath do emaila użytkownika w tokenie ID",
"idpJmespathNamePathOptional": "Ścieżka nazwy (Opcjonalnie)",
@@ -1573,6 +1577,8 @@
"search": "Szukaj…",
"searchPlaceholder": "Szukaj...",
"emptySearchOptions": "Nie znaleziono opcji",
"ipFilterSearchPlaceholder": "Wprowadź adres IP…",
"ipFilterEmptyMessage": "Wprowadź adres IP, aby filtrować",
"create": "Utwórz",
"orgs": "Organizacje",
"loginError": "Wystąpił nieoczekiwany błąd. Spróbuj ponownie.",
@@ -2596,6 +2602,7 @@
"createDomainType": "Typ:",
"createDomainName": "Nazwa:",
"createDomainValue": "Wartość:",
"multiSelectFilterCount": "{count, plural, one {# wybrany} few {# wybrane} many {# wybranych} other {# wybranych}}",
"createDomainCnameRecords": "Rekordy CNAME",
"createDomainARecords": "Rekordy A",
"createDomainRecordNumber": "Rekord {number}",
@@ -2710,6 +2717,7 @@
"healthScheme": "Metoda",
"healthSelectScheme": "Wybierz metodę",
"healthCheckPortInvalid": "Port musi być pomiędzy 1 a 65535",
"healthCheckHostnameInvalid": "Nazwa hosta nie może zawierać spacji",
"healthCheckPath": "Ścieżka",
"healthHostname": "IP / Nazwa hosta",
"healthPort": "Port",
@@ -2993,7 +3001,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "Dodaj zakres CIDR (np. 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Nie udało się załadować podsieci",
"remoteExitNodeNetworkingLabelsTitle": "Etykiety preferencji",
"remoteExitNodeNetworkingLabelsDescription": "Strony z tymi etykietami będą zmuszone do połączenia się przez ten zdalny węzeł wyjściowy.",
"remoteExitNodeNetworkingLabelsDescription": "Strony z tymi etykietami będą preferować połączenie przez ten zdalny węzeł wyjściowy.",
"remoteExitNodeNetworkingLabelsButtonText": "Wybierz etykiety...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Szukaj etykiet...",
"remoteExitNodeNetworkingLabelsLoadError": "Nie udało się załadować etykiet",
+9 -1
View File
@@ -1176,6 +1176,10 @@
"idpJmespathAboutDescriptionLink": "Saiba mais sobre JMESPath",
"idpJmespathLabel": "Caminho do Identificador",
"idpJmespathLabelDescription": "O JMESPath para o identificador do utilizador no token ID",
"idpIdentifierChangeTitle": "Aviso de Mudança no Caminho do Identificador",
"idpIdentifierChangeDescription": "Você está prestes a mudar o caminho do identificador. Isso afetará como os usuários existentes são mapeados. Usuários que anteriormente fizeram login através deste provedor de identidade podem não ser mais reconhecidos como os mesmos usuários.",
"idpIdentifierChangeConfirmMessage": "Eu confirmo",
"idpIdentifierChangeWarningText": "Isto afetará como os usuários existentes são mapeados",
"idpJmespathEmailPathOptional": "Caminho do Email (Opcional)",
"idpJmespathEmailPathOptionalDescription": "O JMESPath para o email do utilizador no token ID",
"idpJmespathNamePathOptional": "Caminho do Nome (Opcional)",
@@ -1573,6 +1577,8 @@
"search": "Pesquisar…",
"searchPlaceholder": "Buscar...",
"emptySearchOptions": "Nenhuma opção encontrada",
"ipFilterSearchPlaceholder": "Insira um endereço IP…",
"ipFilterEmptyMessage": "Insira um endereço IP para filtrar",
"create": "Criar",
"orgs": "Organizações",
"loginError": "Ocorreu um erro inesperado. Por favor, tente novamente.",
@@ -2596,6 +2602,7 @@
"createDomainType": "Tipo:",
"createDomainName": "Nome:",
"createDomainValue": "Valor:",
"multiSelectFilterCount": "{count} selecionado",
"createDomainCnameRecords": "Registros CNAME",
"createDomainARecords": "Registros A",
"createDomainRecordNumber": "Registrar {number}",
@@ -2710,6 +2717,7 @@
"healthScheme": "Método",
"healthSelectScheme": "Selecione o Método",
"healthCheckPortInvalid": "A porta deve estar entre 1 e 65535",
"healthCheckHostnameInvalid": "O nome do host não deve conter espaços em branco",
"healthCheckPath": "Caminho",
"healthHostname": "IP / Nome do Host",
"healthPort": "Porta",
@@ -2993,7 +3001,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "Adicione um intervalo de CIDR (por exemplo, 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Falha ao carregar sub-redes",
"remoteExitNodeNetworkingLabelsTitle": "Etiquetas de Preferência",
"remoteExitNodeNetworkingLabelsDescription": "Os sites com essas etiquetas serão forçados a se conectar através deste nó de saída remoto.",
"remoteExitNodeNetworkingLabelsDescription": "Sites com estas etiquetas preferirão conectar-se por meio deste nó de saída remoto.",
"remoteExitNodeNetworkingLabelsButtonText": "Selecionar etiquetas...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Pesquisar etiquetas...",
"remoteExitNodeNetworkingLabelsLoadError": "Falha ao carregar etiquetas",
+9 -1
View File
@@ -1176,6 +1176,10 @@
"idpJmespathAboutDescriptionLink": "Узнать больше о JMESPath",
"idpJmespathLabel": "Путь идентификатора",
"idpJmespathLabelDescription": "Путь к идентификатору пользователя в ID токене",
"idpIdentifierChangeTitle": "Предупреждение о изменении пути идентификатора",
"idpIdentifierChangeDescription": "Вы собираетесь изменить путь идентификатора. Это повлияет на то, как отображаются существующие пользователи. Пользователи, которые ранее входили через этого поставщика идентификации, могут больше не распознаваться как те же пользователи.",
"idpIdentifierChangeConfirmMessage": "Я подтверждаю",
"idpIdentifierChangeWarningText": "Это повлияет на то, как отображаются существующие пользователи",
"idpJmespathEmailPathOptional": "Путь к email (необязательно)",
"idpJmespathEmailPathOptionalDescription": "Путь к email пользователя в ID токене",
"idpJmespathNamePathOptional": "Путь к имени (необязательно)",
@@ -1573,6 +1577,8 @@
"search": "Поиск…",
"searchPlaceholder": "Поиск...",
"emptySearchOptions": "Опции не найдены",
"ipFilterSearchPlaceholder": "Введите IP адрес…",
"ipFilterEmptyMessage": "Введите IP адрес для фильтрации",
"create": "Создать",
"orgs": "Организации",
"loginError": "Произошла непредвиденная ошибка. Пожалуйста, попробуйте еще раз.",
@@ -2596,6 +2602,7 @@
"createDomainType": "Тип:",
"createDomainName": "Имя:",
"createDomainValue": "Значение:",
"multiSelectFilterCount": "Выбрано: {count}",
"createDomainCnameRecords": "CNAME Записи",
"createDomainARecords": "A Записи",
"createDomainRecordNumber": "Запись {number}",
@@ -2710,6 +2717,7 @@
"healthScheme": "Метод",
"healthSelectScheme": "Выберите метод",
"healthCheckPortInvalid": "Порт должен быть в диапазоне от 1 до 65535",
"healthCheckHostnameInvalid": "Имя хоста не должно содержать пробелов",
"healthCheckPath": "Путь",
"healthHostname": "IP / хост",
"healthPort": "Порт",
@@ -2993,7 +3001,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "Добавить диапазон CIDR (например, 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Не удалось загрузить подсети",
"remoteExitNodeNetworkingLabelsTitle": "Этикетки предпочтений",
"remoteExitNodeNetworkingLabelsDescription": "Сайты с этими метками будут обязаны подключаться через этот удаленный узел выхода.",
"remoteExitNodeNetworkingLabelsDescription": "Сайты с такими метками предпочтут соединяться через этот удаленный узел выхода.",
"remoteExitNodeNetworkingLabelsButtonText": "Выберите метки...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Поиск меток...",
"remoteExitNodeNetworkingLabelsLoadError": "Не удалось загрузить метки",
+9 -1
View File
@@ -1176,6 +1176,10 @@
"idpJmespathAboutDescriptionLink": "JMESPath hakkında daha fazla bilgi edinin",
"idpJmespathLabel": "Tanımlayıcı Yolu",
"idpJmespathLabelDescription": "The JMESPath to the user identifier in the ID token",
"idpIdentifierChangeTitle": "Tanımlayıcı Yol Değişikliği Uyarısı",
"idpIdentifierChangeDescription": "Tanımlayıcı yolu değiştirmek üzeresiniz. Bu, mevcut kullanıcıların nasıl eşleneceğini etkileyecektir. Bu kimlik sağlayıcı üzerinden daha önce oturum açmış olan kullanıcılar artık aynı kullanıcılar olarak tanınmayabilir.",
"idpIdentifierChangeConfirmMessage": "Onaylıyorum",
"idpIdentifierChangeWarningText": "Bu, mevcut kullanıcıların nasıl eşleneceğini etkileyecek",
"idpJmespathEmailPathOptional": "E-posta Yolu (İsteğe Bağlı)",
"idpJmespathEmailPathOptionalDescription": "The JMESPath to the user's email in the ID token",
"idpJmespathNamePathOptional": "Ad Yolu (İsteğe Bağlı)",
@@ -1573,6 +1577,8 @@
"search": "Ara…",
"searchPlaceholder": "Ara...",
"emptySearchOptions": "Seçenek bulunamadı",
"ipFilterSearchPlaceholder": "Bir IP adresi girin…",
"ipFilterEmptyMessage": "Filtrelemek için bir IP adresi girin",
"create": "Oluştur",
"orgs": "Organizasyonlar",
"loginError": "Beklenmeyen bir hata oluştu. Lütfen tekrar deneyin.",
@@ -2596,6 +2602,7 @@
"createDomainType": "Tür:",
"createDomainName": "Ad:",
"createDomainValue": "Değer:",
"multiSelectFilterCount": "{count} seçildi",
"createDomainCnameRecords": "CNAME Kayıtları",
"createDomainARecords": "A Kayıtları",
"createDomainRecordNumber": "Kayıt {number}",
@@ -2710,6 +2717,7 @@
"healthScheme": "Yöntem",
"healthSelectScheme": "Yöntem Seç",
"healthCheckPortInvalid": "Bağlantı noktası 1 ile 65535 arasında olmalıdır",
"healthCheckHostnameInvalid": "Ana bilgisayar adı boşluk içermemelidir",
"healthCheckPath": "Yol",
"healthHostname": "IP / Hostname",
"healthPort": "Bağlantı Noktası",
@@ -2993,7 +3001,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "Bir CIDR aralığı ekle (örneğin, 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Alt ağlar yüklenemedi",
"remoteExitNodeNetworkingLabelsTitle": "Tercih Etiketleri",
"remoteExitNodeNetworkingLabelsDescription": "Bu etiketlere sahip siteler, bu uzak çıkış düğümü üzerinden bağlantı kurmaya zorlanacaktır.",
"remoteExitNodeNetworkingLabelsDescription": "Bu etiketlere sahip siteler, bağlantıyı bu uzak çıkış düğümü üzerinden tercih edecektir.",
"remoteExitNodeNetworkingLabelsButtonText": "Etiketleri seç...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Etiketleri ara...",
"remoteExitNodeNetworkingLabelsLoadError": "Etiketler yüklenemedi",
+9 -1
View File
@@ -1176,6 +1176,10 @@
"idpJmespathAboutDescriptionLink": "了解更多 JMESPath 信息",
"idpJmespathLabel": "标识符路径",
"idpJmespathLabelDescription": "ID 令牌中用户标识符的路径",
"idpIdentifierChangeTitle": "标识符路径更改警告",
"idpIdentifierChangeDescription": "您即将更改标识符路径。这将影响现有用户的映射方式。以前通过此身份提供者登录的用户可能将不再被识别为相同用户。",
"idpIdentifierChangeConfirmMessage": "我确认",
"idpIdentifierChangeWarningText": "这将影响现有用户的映射方式",
"idpJmespathEmailPathOptional": "邮箱路径(可选)",
"idpJmespathEmailPathOptionalDescription": "ID 令牌中用户邮箱的路径",
"idpJmespathNamePathOptional": "用户名路径(可选)",
@@ -1573,6 +1577,8 @@
"search": "搜索…",
"searchPlaceholder": "搜索...",
"emptySearchOptions": "未找到选项",
"ipFilterSearchPlaceholder": "输入IP地址…",
"ipFilterEmptyMessage": "输入要筛选的IP地址",
"create": "创建",
"orgs": "组织",
"loginError": "发生意外错误。请重试。",
@@ -2596,6 +2602,7 @@
"createDomainType": "类型:",
"createDomainName": "名称:",
"createDomainValue": "值:",
"multiSelectFilterCount": "{count} 已选择",
"createDomainCnameRecords": "CNAME 记录",
"createDomainARecords": "A记录",
"createDomainRecordNumber": "记录 {number}",
@@ -2710,6 +2717,7 @@
"healthScheme": "方法",
"healthSelectScheme": "选择方法",
"healthCheckPortInvalid": "端口必须在 1 和 65535 之间",
"healthCheckHostnameInvalid": "主机名不得包含空格",
"healthCheckPath": "路径",
"healthHostname": "IP / 主机",
"healthPort": "端口",
@@ -2993,7 +3001,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "添加CIDR范围(例如10.0.0.0/8",
"remoteExitNodeNetworkingSubnetsLoadError": "无法加载子网",
"remoteExitNodeNetworkingLabelsTitle": "首选标签",
"remoteExitNodeNetworkingLabelsDescription": "有这些标签的站点将强制通过此远程出口节点连接。",
"remoteExitNodeNetworkingLabelsDescription": "有这些标签的站点将优先通过此远程出口节点进行连接。",
"remoteExitNodeNetworkingLabelsButtonText": "选择标签……",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "搜索标签……",
"remoteExitNodeNetworkingLabelsLoadError": "无法加载标签",
+1725 -3380
View File
File diff suppressed because it is too large Load Diff
+76 -73
View File
@@ -32,49 +32,50 @@
"format": "prettier --write ."
},
"dependencies": {
"@asteasolutions/zod-to-openapi": "8.5.0",
"@aws-sdk/client-s3": "3.1056.0",
"@asteasolutions/zod-to-openapi": "9.1.0",
"@aws-sdk/client-s3": "3.1121.0",
"@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
"@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.1.tgz",
"@headlessui/react": "2.2.10",
"@hookform/resolvers": "5.4.0",
"@hookform/resolvers": "5.9.1",
"@monaco-editor/react": "4.7.0",
"@node-rs/argon2": "2.0.2",
"@node-rs/argon2": "2.2.0",
"@novnc/novnc": "^1.7.0",
"@oslojs/crypto": "1.0.1",
"@oslojs/encoding": "1.1.0",
"@radix-ui/react-avatar": "1.1.11",
"@radix-ui/react-checkbox": "1.3.3",
"@radix-ui/react-collapsible": "1.1.12",
"@radix-ui/react-dialog": "1.1.15",
"@radix-ui/react-dropdown-menu": "2.1.16",
"@radix-ui/react-avatar": "1.2.6",
"@radix-ui/react-checkbox": "1.3.11",
"@radix-ui/react-collapsible": "1.1.20",
"@radix-ui/react-dialog": "1.1.23",
"@radix-ui/react-dropdown-menu": "2.1.24",
"@radix-ui/react-icons": "1.3.2",
"@radix-ui/react-label": "2.1.8",
"@radix-ui/react-popover": "1.1.15",
"@radix-ui/react-progress": "1.1.8",
"@radix-ui/react-radio-group": "1.3.8",
"@radix-ui/react-scroll-area": "1.2.10",
"@radix-ui/react-select": "2.2.6",
"@radix-ui/react-separator": "1.1.8",
"@radix-ui/react-slot": "1.2.4",
"@radix-ui/react-switch": "1.2.6",
"@radix-ui/react-tabs": "1.1.13",
"@radix-ui/react-toast": "1.2.15",
"@radix-ui/react-tooltip": "1.2.8",
"@radix-ui/react-label": "2.1.15",
"@radix-ui/react-popover": "1.1.23",
"@radix-ui/react-progress": "1.1.16",
"@radix-ui/react-radio-group": "1.4.7",
"@radix-ui/react-scroll-area": "1.2.18",
"@radix-ui/react-select": "2.3.7",
"@radix-ui/react-separator": "1.1.15",
"@radix-ui/react-slot": "1.3.3",
"@radix-ui/react-switch": "1.3.7",
"@radix-ui/react-tabs": "1.1.21",
"@radix-ui/react-toast": "1.2.23",
"@radix-ui/react-tooltip": "1.2.16",
"@react-email/body": "0.3.0",
"@react-email/components": "1.0.12",
"@react-email/render": "2.0.8",
"@react-email/render": "2.1.0",
"@react-email/tailwind": "2.0.7",
"@simplewebauthn/browser": "13.3.0",
"@simplewebauthn/server": "13.3.1",
"@simplewebauthn/server": "13.3.3",
"@tailwindcss/forms": "0.5.11",
"@tanstack/react-query": "5.100.14",
"@tanstack/react-query": "5.102.8",
"@tanstack/react-table": "8.21.3",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"acme-client": "^5.4.0",
"arctic": "3.7.0",
"axios": "1.18.0",
"axios": "1.20.0",
"better-sqlite3": "11.9.1",
"canvas-confetti": "1.9.4",
"class-variance-authority": "0.7.1",
@@ -84,104 +85,106 @@
"cors": "2.8.6",
"crypto-js": "4.2.0",
"d3": "7.9.0",
"dns-packet": "^5.6.1",
"drizzle-orm": "0.45.2",
"express": "5.2.1",
"express-rate-limit": "8.5.2",
"express-rate-limit": "8.7.0",
"glob": "13.0.6",
"gpt-tokenizer": "^3.4.0",
"helmet": "8.2.0",
"gpt-tokenizer": "^4.0.0",
"helmet": "8.3.0",
"http-errors": "2.0.1",
"input-otp": "1.4.2",
"ioredis": "5.11.0",
"input-otp": "1.5.0",
"ioredis": "6.0.0",
"jmespath": "0.16.0",
"js-yaml": "4.3.1",
"js-yaml": "5.4.1",
"jsonwebtoken": "9.0.3",
"lucide-react": "1.17.0",
"maxmind": "5.0.6",
"lucide-react": "1.38.0",
"maxmind": "5.0.7",
"moment": "2.30.1",
"next": "16.3.1",
"next-intl": "4.13.0",
"next": "16.3.3",
"next-intl": "4.14.1",
"next-themes": "0.4.6",
"nextjs-toploader": "3.9.17",
"node-cache": "5.1.2",
"nodemailer": "9.0.1",
"nodemailer": "9.1.0",
"oslo": "1.2.1",
"pg": "8.21.0",
"posthog-node": "5.35.6",
"pg": "8.23.0",
"posthog-node": "5.51.4",
"qrcode.react": "4.2.0",
"react": "19.2.6",
"react": "19.2.8",
"react-day-picker": "9.14.0",
"react-dom": "19.2.6",
"react-dom": "19.2.8",
"react-easy-sort": "1.8.0",
"react-hook-form": "7.76.1",
"react-icons": "5.6.0",
"recharts": "3.8.1",
"react-hook-form": "7.87.0",
"react-icons": "5.7.0",
"recharts": "3.10.1",
"reodotdev": "1.1.0",
"semver": "7.8.1",
"semver": "7.8.5",
"sshpk": "1.18.0",
"stripe": "22.2.0",
"stripe": "22.6.0",
"swagger-ui-express": "5.0.1",
"tailwind-merge": "3.6.0",
"topojson-client": "3.1.0",
"tw-animate-css": "1.4.0",
"use-debounce": "10.1.1",
"uuid": "14.0.0",
"uuid": "14.0.2",
"vaul": "1.1.2",
"visionscarto-world-atlas": "1.0.0",
"winston": "3.19.0",
"winston-daily-rotate-file": "5.0.0",
"ws": "8.21.0",
"ws": "8.21.3",
"yaml": "2.9.0",
"yargs": "18.0.0",
"zod": "4.4.3",
"yargs": "18.1.0",
"zod": "4.5.4",
"zod-validation-error": "5.0.0"
},
"devDependencies": {
"@dotenvx/dotenvx": "1.69.1",
"@dotenvx/dotenvx": "2.23.0",
"@esbuild-plugins/tsconfig-paths": "0.1.2",
"@react-email/ui": "^6.9.2",
"@tailwindcss/postcss": "4.3.0",
"@tanstack/react-query-devtools": "5.100.14",
"@react-email/ui": "^6.9.3",
"@tailwindcss/postcss": "4.3.3",
"@tanstack/react-query-devtools": "5.102.8",
"@types/better-sqlite3": "7.6.13",
"@types/cookie-parser": "1.4.10",
"@types/cors": "2.8.19",
"@types/crypto-js": "4.2.2",
"@types/d3": "7.4.3",
"@types/dns-packet": "^5.6.5",
"@types/express": "5.0.6",
"@types/express-session": "1.19.0",
"@types/jmespath": "0.15.2",
"@types/js-yaml": "4.0.9",
"@types/jsonwebtoken": "9.0.10",
"@types/node": "25.9.1",
"@types/nodemailer": "8.0.0",
"@types/node": "26.4.0",
"@types/nodemailer": "8.0.1",
"@types/nprogress": "0.2.3",
"@types/pg": "8.20.0",
"@types/react": "19.2.15",
"@types/react-dom": "19.2.3",
"@types/semver": "7.7.1",
"@types/sshpk": "1.17.4",
"@types/pg": "8.23.1",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.5",
"@types/semver": "7.8.0",
"@types/sshpk": "1.17.5",
"@types/swagger-ui-express": "4.1.8",
"@types/topojson-client": "3.1.5",
"@types/ws": "8.18.1",
"@types/yargs": "17.0.35",
"babel-plugin-react-compiler": "1.0.0",
"drizzle-kit": "0.31.10",
"esbuild": "0.28.0",
"esbuild-node-externals": "1.22.0",
"eslint": "10.4.0",
"eslint-config-next": "16.2.6",
"postcss": "8.5.23",
"prettier": "3.8.3",
"react-email": "6.5.0",
"tailwindcss": "4.3.0",
"tsc-alias": "1.8.17",
"tsx": "4.22.3",
"esbuild": "0.28.2",
"esbuild-node-externals": "2.0.0",
"eslint": "10.9.1",
"eslint-config-next": "16.3.3",
"postcss": "8.5.26",
"prettier": "3.9.6",
"react-email": "6.9.3",
"tailwindcss": "4.3.3",
"tsc-alias": "1.9.2",
"tsx": "4.23.13",
"typescript": "6.0.3",
"typescript-eslint": "8.60.0"
"typescript-eslint": "8.68.0"
},
"overrides": {
"esbuild": "0.28.0",
"esbuild": "0.28.2",
"dompurify": "3.4.0",
"postcss": "8.5.23"
"postcss": "8.5.26"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 621 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 532 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 621 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 556 KiB

After

Width:  |  Height:  |  Size: 620 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 574 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 516 KiB

After

Width:  |  Height:  |  Size: 800 KiB

+8
View File
@@ -0,0 +1,8 @@
export async function startCertificateManager() {
// No-op: ACME certificate generation/management is only available in
// builds that include the private/enterprise feature set.
}
export async function stopCertificateManager() {
// No-op counterpart to startCertificateManager.
}
+85 -1
View File
@@ -26,7 +26,9 @@ import {
sites,
clients,
sessions,
labels
labels,
aiProviders,
virtualApiKeys
} from "./schema";
export const dnsChallenge = pgTable("dnsChallenges", {
@@ -614,6 +616,87 @@ export const trialNotifications = pgTable("trialNotifications", {
sentAt: bigint("sentAt", { mode: "number" }).notNull()
});
// Logs the aggregated prompt + response for a single AI gateway request, for
// session replay. One row per request (not per streaming chunk). `sessionId`
// is a fresh random id per row for now - no cross-request correlation yet,
// but the column exists so a future pass can link multiple rows into a real
// multi-turn session.
export const aiSessionLog = pgTable(
"aiSessionLog",
{
id: serial("id").primaryKey(),
sessionId: varchar("sessionId").notNull(),
orgId: varchar("orgId").references(() => orgs.orgId, {
onDelete: "cascade"
}),
providerId: integer("providerId").references(
() => aiProviders.providerId,
{ onDelete: "set null" }
),
capability: varchar("capability").notNull(),
resourceId: integer("resourceId").references(
() => resources.resourceId,
{ onDelete: "set null" }
),
siteResourceId: integer("siteResourceId").references(
() => siteResources.siteResourceId,
{ onDelete: "set null" }
),
userId: varchar("userId").references(() => users.userId, {
onDelete: "set null"
}),
virtualApiKeyId: varchar("virtualApiKeyId").references(
() => virtualApiKeys.virtualApiKeyId,
{ onDelete: "set null" }
),
requestedModel: varchar("requestedModel"),
isStream: boolean("isStream").notNull().default(false),
requestBody: text("requestBody"),
responseBody: text("responseBody"),
// Capability-agnostic message transcript (JSON-encoded
// NormalizedAiMessage[] from server/lib/aiMessageNormalization.ts),
// computed at write time so search/display never need per-capability
// parsing logic. Null when normalization couldn't recognize the
// shape - callers fall back to requestBody/responseBody.
normalizedRequest: text("normalizedRequest"),
normalizedResponse: text("normalizedResponse"),
// True if any of the request/response (raw or normalized) fields
// were cut short at AI_SESSION_LOG_MAX_BODY_CHARS before storage.
truncated: boolean("truncated").notNull().default(false),
statusCode: integer("statusCode"),
createdAt: bigint("createdAt", { mode: "number" }).notNull() // epoch seconds
},
(t) => [
index("idx_ai_session_log_org_created").on(t.orgId, t.createdAt),
index("idx_ai_session_log_org_provider_created").on(
t.orgId,
t.providerId,
t.createdAt
),
index("idx_ai_session_log_org_resource_created").on(
t.orgId,
t.resourceId,
t.createdAt
),
index("idx_ai_session_log_org_site_resource_created").on(
t.orgId,
t.siteResourceId,
t.createdAt
),
index("idx_ai_session_log_org_user_created").on(
t.orgId,
t.userId,
t.createdAt
),
index("idx_ai_session_log_org_virtual_api_key_created").on(
t.orgId,
t.virtualApiKeyId,
t.createdAt
),
index("idx_ai_session_log_session").on(t.sessionId)
]
);
export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>;
@@ -660,3 +743,4 @@ export type AlertEmailRecipients = InferSelectModel<
>;
export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>;
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
export type AiSessionLog = InferSelectModel<typeof aiSessionLog>;
-82
View File
@@ -1958,87 +1958,6 @@ export const aiBudgetBreachEvents = pgTable(
]
);
// Logs the aggregated prompt + response for a single AI gateway request, for
// session replay. One row per request (not per streaming chunk). `sessionId`
// is a fresh random id per row for now - no cross-request correlation yet,
// but the column exists so a future pass can link multiple rows into a real
// multi-turn session.
export const aiSessionLog = pgTable(
"aiSessionLog",
{
id: serial("id").primaryKey(),
sessionId: varchar("sessionId").notNull(),
orgId: varchar("orgId").references(() => orgs.orgId, {
onDelete: "cascade"
}),
providerId: integer("providerId").references(
() => aiProviders.providerId,
{ onDelete: "set null" }
),
capability: varchar("capability").notNull(),
resourceId: integer("resourceId").references(
() => resources.resourceId,
{ onDelete: "set null" }
),
siteResourceId: integer("siteResourceId").references(
() => siteResources.siteResourceId,
{ onDelete: "set null" }
),
userId: varchar("userId").references(() => users.userId, {
onDelete: "set null"
}),
virtualApiKeyId: varchar("virtualApiKeyId").references(
() => virtualApiKeys.virtualApiKeyId,
{ onDelete: "set null" }
),
requestedModel: varchar("requestedModel"),
isStream: boolean("isStream").notNull().default(false),
requestBody: text("requestBody"),
responseBody: text("responseBody"),
// Capability-agnostic message transcript (JSON-encoded
// NormalizedAiMessage[] from server/lib/aiMessageNormalization.ts),
// computed at write time so search/display never need per-capability
// parsing logic. Null when normalization couldn't recognize the
// shape - callers fall back to requestBody/responseBody.
normalizedRequest: text("normalizedRequest"),
normalizedResponse: text("normalizedResponse"),
// True if any of the request/response (raw or normalized) fields
// were cut short at AI_SESSION_LOG_MAX_BODY_CHARS before storage.
truncated: boolean("truncated").notNull().default(false),
statusCode: integer("statusCode"),
createdAt: bigint("createdAt", { mode: "number" }).notNull() // epoch seconds
},
(t) => [
index("idx_ai_session_log_org_created").on(t.orgId, t.createdAt),
index("idx_ai_session_log_org_provider_created").on(
t.orgId,
t.providerId,
t.createdAt
),
index("idx_ai_session_log_org_resource_created").on(
t.orgId,
t.resourceId,
t.createdAt
),
index("idx_ai_session_log_org_site_resource_created").on(
t.orgId,
t.siteResourceId,
t.createdAt
),
index("idx_ai_session_log_org_user_created").on(
t.orgId,
t.userId,
t.createdAt
),
index("idx_ai_session_log_org_virtual_api_key_created").on(
t.orgId,
t.virtualApiKeyId,
t.createdAt
),
index("idx_ai_session_log_session").on(t.sessionId)
]
);
export const certificates = pgTable("certificates", {
certId: serial("certId").primaryKey(),
domain: varchar("domain", { length: 255 }).notNull().unique(),
@@ -2151,7 +2070,6 @@ export type AiModel = InferSelectModel<typeof aiModels>;
export type AiBudget = InferSelectModel<typeof aiBudgets>;
export type AiUsageRecord = InferSelectModel<typeof aiUsageRecords>;
export type AiBudgetBreachEvent = InferSelectModel<typeof aiBudgetBreachEvents>;
export type AiSessionLog = InferSelectModel<typeof aiSessionLog>;
export type ResourceAiProvider = InferSelectModel<typeof resourceAiProviders>;
export type SiteResourceAiProvider = InferSelectModel<
typeof siteResourceAiProviders
+89 -1
View File
@@ -9,6 +9,7 @@ import {
uniqueIndex
} from "drizzle-orm/sqlite-core";
import {
aiProviders,
clients,
domains,
exitNodes,
@@ -20,7 +21,8 @@ import {
siteResources,
sites,
targetHealthCheck,
users
users,
virtualApiKeys
} from "./schema";
export const dnsChallenge = sqliteTable("dnsChallenges", {
@@ -609,6 +611,91 @@ export const trialNotifications = sqliteTable("trialNotifications", {
sentAt: integer("sentAt").notNull()
});
// Logs the aggregated prompt + response for a single AI gateway request, for
// session replay. One row per request (not per streaming chunk). `sessionId`
// is a fresh random id per row for now - no cross-request correlation yet,
// but the column exists so a future pass can link multiple rows into a real
// multi-turn session.
export const aiSessionLog = sqliteTable(
"aiSessionLog",
{
id: integer("id").primaryKey({ autoIncrement: true }),
sessionId: text("sessionId").notNull(),
orgId: text("orgId").references(() => orgs.orgId, {
onDelete: "cascade"
}),
providerId: integer("providerId").references(
() => aiProviders.providerId,
{ onDelete: "set null" }
),
capability: text("capability").notNull(),
resourceId: integer("resourceId").references(
() => resources.resourceId,
{ onDelete: "set null" }
),
siteResourceId: integer("siteResourceId").references(
() => siteResources.siteResourceId,
{ onDelete: "set null" }
),
userId: text("userId").references(() => users.userId, {
onDelete: "set null"
}),
virtualApiKeyId: text("virtualApiKeyId").references(
() => virtualApiKeys.virtualApiKeyId,
{ onDelete: "set null" }
),
requestedModel: text("requestedModel"),
isStream: integer("isStream", { mode: "boolean" })
.notNull()
.default(false),
requestBody: text("requestBody"),
responseBody: text("responseBody"),
// Capability-agnostic message transcript (JSON-encoded
// NormalizedAiMessage[] from server/lib/aiMessageNormalization.ts),
// computed at write time so search/display never need per-capability
// parsing logic. Null when normalization couldn't recognize the
// shape - callers fall back to requestBody/responseBody.
normalizedRequest: text("normalizedRequest"),
normalizedResponse: text("normalizedResponse"),
// True if any of the request/response (raw or normalized) fields
// were cut short at AI_SESSION_LOG_MAX_BODY_CHARS before storage.
truncated: integer("truncated", { mode: "boolean" })
.notNull()
.default(false),
statusCode: integer("statusCode"),
createdAt: integer("createdAt").notNull() // epoch seconds
},
(t) => [
index("idx_ai_session_log_org_created").on(t.orgId, t.createdAt),
index("idx_ai_session_log_org_provider_created").on(
t.orgId,
t.providerId,
t.createdAt
),
index("idx_ai_session_log_org_resource_created").on(
t.orgId,
t.resourceId,
t.createdAt
),
index("idx_ai_session_log_org_site_resource_created").on(
t.orgId,
t.siteResourceId,
t.createdAt
),
index("idx_ai_session_log_org_user_created").on(
t.orgId,
t.userId,
t.createdAt
),
index("idx_ai_session_log_org_virtual_api_key_created").on(
t.orgId,
t.virtualApiKeyId,
t.createdAt
),
index("idx_ai_session_log_session").on(t.sessionId)
]
);
export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>;
@@ -647,3 +734,4 @@ export type AlertEmailAction = InferSelectModel<typeof alertEmailActions>;
export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>;
export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>;
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
export type AiSessionLog = InferSelectModel<typeof aiSessionLog>;
+34 -118
View File
@@ -147,9 +147,7 @@ export const sites = sqliteTable(
.$type<"pending" | "approved">()
.default("approved")
},
(table) => [
index("idx_sites_orgId").on(table.orgId)
]
(table) => [index("idx_sites_orgId").on(table.orgId)]
);
export const resources = sqliteTable(
@@ -192,7 +190,9 @@ export const resources = sqliteTable(
mode: "boolean"
}),
applyRules: integer("applyRules", { mode: "boolean" }),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
enabled: integer("enabled", { mode: "boolean" })
.notNull()
.default(true),
stickySession: integer("stickySession", { mode: "boolean" })
.notNull()
.default(false),
@@ -220,10 +220,14 @@ export const resources = sqliteTable(
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
postAuthPath: text("postAuthPath"),
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown"
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false),
wildcard: integer("wildcard", { mode: "boolean" })
.notNull()
.default(false),
mode: text("mode")
.default("http")
.$type<"rdp" | "ssh" | "http" | "vnc" | "inference" | "tcp" | "udp">()
.$type<
"rdp" | "ssh" | "http" | "vnc" | "inference" | "tcp" | "udp"
>()
.notNull(), // rdp, ssh, http, vnc, inference
pamMode: text("pamMode")
.$type<"passthrough" | "push">()
@@ -236,9 +240,7 @@ export const resources = sqliteTable(
.$type<"pending" | "approved">()
.default("approved")
},
(table) => [
index("idx_resources_orgId").on(table.orgId)
]
(table) => [index("idx_resources_orgId").on(table.orgId)]
);
export const resourceAiProviders = sqliteTable(
@@ -288,9 +290,7 @@ export const labels = sqliteTable(
})
.notNull()
},
(table) => [
index("idx_labels_orgId").on(table.orgId)
]
(table) => [index("idx_labels_orgId").on(table.orgId)]
);
export const launcherViews = sqliteTable("launcherViews", {
@@ -409,7 +409,9 @@ export const targets = sqliteTable(
method: text("method"),
port: integer("port").notNull(),
internalPort: integer("internalPort"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
enabled: integer("enabled", { mode: "boolean" })
.notNull()
.default(true),
path: text("path"),
pathMatchType: text("pathMatchType"), // exact, prefix, regex
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
@@ -705,9 +707,7 @@ export const newts = sqliteTable(
onDelete: "cascade"
})
},
(table) => [
index("idx_newts_siteId").on(table.siteId)
]
(table) => [index("idx_newts_siteId").on(table.siteId)]
);
export const clients = sqliteTable(
@@ -740,8 +740,12 @@ export const clients = sqliteTable(
online: integer("online", { mode: "boolean" }).notNull().default(false),
// endpoint: text("endpoint"),
lastHolePunch: integer("lastHolePunch"),
archived: integer("archived", { mode: "boolean" }).notNull().default(false),
blocked: integer("blocked", { mode: "boolean" }).notNull().default(false),
archived: integer("archived", { mode: "boolean" })
.notNull()
.default(false),
blocked: integer("blocked", { mode: "boolean" })
.notNull()
.default(false),
approvalState: text("approvalState").$type<
"pending" | "approved" | "denied"
>()
@@ -795,11 +799,11 @@ export const olms = sqliteTable(
// optionally tied to a user and in this case delete when the user deletes
onDelete: "cascade"
}),
archived: integer("archived", { mode: "boolean" }).notNull().default(false)
archived: integer("archived", { mode: "boolean" })
.notNull()
.default(false)
},
(table) => [
index("idx_olms_userId").on(table.userId)
]
(table) => [index("idx_olms_userId").on(table.userId)]
);
export const currentFingerprint = sqliteTable("currentFingerprint", {
@@ -975,9 +979,7 @@ export const sessions = sqliteTable(
.notNull()
.default(false)
},
(table) => [
index("idx_sessions_userId").on(table.userId)
]
(table) => [index("idx_sessions_userId").on(table.userId)]
);
export const newtSessions = sqliteTable("newtSession", {
@@ -1007,7 +1009,9 @@ export const userOrgs = sqliteTable(
onDelete: "cascade"
})
.notNull(),
isOwner: integer("isOwner", { mode: "boolean" }).notNull().default(false),
isOwner: integer("isOwner", { mode: "boolean" })
.notNull()
.default(false),
autoProvisioned: integer("autoProvisioned", {
mode: "boolean"
}).default(false),
@@ -1062,14 +1066,12 @@ export const roles = sqliteTable(
}).default(false),
sshSudoMode: text("sshSudoMode").default("full"), // "none" | "full" | "commands"
sshSudoCommands: text("sshSudoCommands").default("[]"),
sshCreateHomeDir: integer("sshCreateHomeDir", { mode: "boolean" }).default(
true
),
sshCreateHomeDir: integer("sshCreateHomeDir", {
mode: "boolean"
}).default(true),
sshUnixGroups: text("sshUnixGroups").default("[]")
},
(table) => [
index("idx_roles_orgId").on(table.orgId)
]
(table) => [index("idx_roles_orgId").on(table.orgId)]
);
export const userOrgRoles = sqliteTable(
@@ -1997,91 +1999,6 @@ export const aiBudgetBreachEvents = sqliteTable(
]
);
// Logs the aggregated prompt + response for a single AI gateway request, for
// session replay. One row per request (not per streaming chunk). `sessionId`
// is a fresh random id per row for now - no cross-request correlation yet,
// but the column exists so a future pass can link multiple rows into a real
// multi-turn session.
export const aiSessionLog = sqliteTable(
"aiSessionLog",
{
id: integer("id").primaryKey({ autoIncrement: true }),
sessionId: text("sessionId").notNull(),
orgId: text("orgId").references(() => orgs.orgId, {
onDelete: "cascade"
}),
providerId: integer("providerId").references(
() => aiProviders.providerId,
{ onDelete: "set null" }
),
capability: text("capability").notNull(),
resourceId: integer("resourceId").references(
() => resources.resourceId,
{ onDelete: "set null" }
),
siteResourceId: integer("siteResourceId").references(
() => siteResources.siteResourceId,
{ onDelete: "set null" }
),
userId: text("userId").references(() => users.userId, {
onDelete: "set null"
}),
virtualApiKeyId: text("virtualApiKeyId").references(
() => virtualApiKeys.virtualApiKeyId,
{ onDelete: "set null" }
),
requestedModel: text("requestedModel"),
isStream: integer("isStream", { mode: "boolean" })
.notNull()
.default(false),
requestBody: text("requestBody"),
responseBody: text("responseBody"),
// Capability-agnostic message transcript (JSON-encoded
// NormalizedAiMessage[] from server/lib/aiMessageNormalization.ts),
// computed at write time so search/display never need per-capability
// parsing logic. Null when normalization couldn't recognize the
// shape - callers fall back to requestBody/responseBody.
normalizedRequest: text("normalizedRequest"),
normalizedResponse: text("normalizedResponse"),
// True if any of the request/response (raw or normalized) fields
// were cut short at AI_SESSION_LOG_MAX_BODY_CHARS before storage.
truncated: integer("truncated", { mode: "boolean" })
.notNull()
.default(false),
statusCode: integer("statusCode"),
createdAt: integer("createdAt").notNull() // epoch seconds
},
(t) => [
index("idx_ai_session_log_org_created").on(t.orgId, t.createdAt),
index("idx_ai_session_log_org_provider_created").on(
t.orgId,
t.providerId,
t.createdAt
),
index("idx_ai_session_log_org_resource_created").on(
t.orgId,
t.resourceId,
t.createdAt
),
index("idx_ai_session_log_org_site_resource_created").on(
t.orgId,
t.siteResourceId,
t.createdAt
),
index("idx_ai_session_log_org_user_created").on(
t.orgId,
t.userId,
t.createdAt
),
index("idx_ai_session_log_org_virtual_api_key_created").on(
t.orgId,
t.virtualApiKeyId,
t.createdAt
),
index("idx_ai_session_log_session").on(t.sessionId)
]
);
export const certificates = sqliteTable("certificates", {
certId: integer("certId").primaryKey({ autoIncrement: true }),
domain: text("domain").notNull().unique(),
@@ -2192,7 +2109,6 @@ export type AiModel = InferSelectModel<typeof aiModels>;
export type AiBudget = InferSelectModel<typeof aiBudgets>;
export type AiUsageRecord = InferSelectModel<typeof aiUsageRecords>;
export type AiBudgetBreachEvent = InferSelectModel<typeof aiBudgetBreachEvents>;
export type AiSessionLog = InferSelectModel<typeof aiSessionLog>;
export type ResourceAiProvider = InferSelectModel<typeof resourceAiProviders>;
export type SiteResourceAiProvider = InferSelectModel<
typeof siteResourceAiProviders
+8
View File
@@ -0,0 +1,8 @@
export async function startDnsServer() {
// No-op: the authoritative DNS server is only available in builds
// that include the private/enterprise feature set.
}
export async function stopDnsServer() {
// No-op counterpart to startDnsServer.
}
+6
View File
@@ -25,6 +25,8 @@ import { setHostMeta } from "@server/lib/hostMeta";
import { TraefikConfigManager } from "@server/lib/traefik/TraefikConfigManager";
import { initCleanup } from "#dynamic/cleanup";
import { startSchedulers } from "#dynamic/startSchedulers";
import { startDnsServer } from "#dynamic/dns";
import { startCertificateManager } from "#dynamic/certificates";
import license from "#dynamic/license/license";
import { fetchServerIp } from "@server/lib/serverIpService";
import { initAiModelCatalog } from "@server/lib/aiModelCatalog";
@@ -45,6 +47,10 @@ async function startServers() {
startSchedulers();
await startDnsServer();
await startCertificateManager();
// Start all servers
const apiServer = createApiServer();
const internalServer = createInternalServer();
+1 -1
View File
@@ -16,7 +16,7 @@ import { registry, openApiTags } from "./openApi";
import fs from "fs";
import path from "path";
import { APP_PATH } from "./lib/consts";
import yaml from "js-yaml";
import * as yaml from "js-yaml";
import { z } from "zod";
const dev = process.env.ENVIRONMENT !== "prod";
+31 -19
View File
@@ -121,25 +121,37 @@ export async function applyBlueprint({
(hc) => hc.targetId === target.targetId
);
if (["http", "tcp", "udp"].includes(target.mode)) {
await addProxyTargets(
site.newt.newtId,
[target],
matchingHealthcheck
? [matchingHealthcheck]
: [],
result.proxyResource.mode === "udp"
? "udp"
: "tcp",
site.newt.version
);
} else if (
["ssh", "rdp", "vnc"].includes(target.mode)
) {
await sendBrowserGatewayTargets(
site.newt.newtId,
[target],
site.newt.version
// The DB writes for all resources have already committed
// by this point, so a push failure for one target (e.g.
// a newt rejecting a malformed health check) must not
// abort pushing the rest, and must not mark the whole
// blueprint as failed when the config was actually
// persisted successfully.
try {
if (["http", "tcp", "udp"].includes(target.mode)) {
await addProxyTargets(
site.newt.newtId,
[target],
matchingHealthcheck
? [matchingHealthcheck]
: [],
result.proxyResource.mode === "udp"
? "udp"
: "tcp",
site.newt.version
);
} else if (
["ssh", "rdp", "vnc"].includes(target.mode)
) {
await sendBrowserGatewayTargets(
site.newt.newtId,
[target],
site.newt.version
);
}
} catch (e) {
logger.error(
`Failed to push target ${target.targetId} to newt on site ${site.sites.siteId}. Error: ${e}`
);
}
}
+11 -8
View File
@@ -1,12 +1,12 @@
import { and, asc, eq, or } from "drizzle-orm";
import { Transaction, User, userOrgs, users } from "@server/db";
export async function findOrgUserByIdentifier(
export async function findOrgUsersByIdentifier(
trx: Transaction,
orgId: string,
identifier: string
): Promise<User | null> {
const [match] = await trx
): Promise<User[]> {
const matches = await trx
.select()
.from(users)
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
@@ -16,10 +16,9 @@ export async function findOrgUserByIdentifier(
eq(userOrgs.orgId, orgId)
)
)
.orderBy(asc(users.dateCreated), asc(users.userId))
.limit(1);
.orderBy(asc(users.dateCreated), asc(users.userId));
return match?.user ?? null;
return matches.map((match) => match.user);
}
export async function resolveOrgUserIds(
@@ -29,8 +28,12 @@ export async function resolveOrgUserIds(
): Promise<string[]> {
const userIds = new Set<string>();
for (const identifier of identifiers) {
const user = await findOrgUserByIdentifier(trx, orgId, identifier);
if (user) {
const matchedUsers = await findOrgUsersByIdentifier(
trx,
orgId,
identifier
);
for (const user of matchedUsers) {
userIds.add(user.userId);
}
}
+33 -21
View File
@@ -51,7 +51,7 @@ import { tierMatrix } from "../billing/tierMatrix";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
import { Config, isTargetsOnlyResource, TargetData } from "./types";
import { getOrCreateLabelIds, syncResourceLabels } from "./labels";
import { findOrgUserByIdentifier } from "./findOrgUser";
import { findOrgUsersByIdentifier } from "./findOrgUser";
import { LimitId } from "../billing";
import { usageService } from "../billing/usageService";
import { syncInferenceAiConfig } from "./aiProviders";
@@ -1564,21 +1564,27 @@ async function syncUserResources(
.where(eq(userResources.resourceId, resourceId));
for (const username of ssoUsers) {
const user = await findOrgUserByIdentifier(trx, orgId, username);
const matchedUsers = await findOrgUsersByIdentifier(
trx,
orgId,
username
);
if (!user) {
if (matchedUsers.length === 0) {
throw new Error(`User not found: ${username} in org ${orgId}`);
}
const existingUserResource = existingUserResources.find(
(rr) => rr.userId === user.userId
);
for (const user of matchedUsers) {
const existingUserResource = existingUserResources.find(
(rr) => rr.userId === user.userId
);
if (!existingUserResource) {
await trx.insert(userResources).values({
userId: user.userId,
resourceId: resourceId
});
if (!existingUserResource) {
await trx.insert(userResources).values({
userId: user.userId,
resourceId: resourceId
});
}
}
}
@@ -1946,21 +1952,27 @@ async function syncUserPolicies(
.where(eq(userPolicies.resourcePolicyId, policyId));
for (const username of ssoUsers) {
const user = await findOrgUserByIdentifier(trx, orgId, username);
const matchedUsers = await findOrgUsersByIdentifier(
trx,
orgId,
username
);
if (!user) {
if (matchedUsers.length === 0) {
throw new Error(`User not found: ${username} in org ${orgId}`);
}
const existingUserPolicy = existingUserPoliciesList.find(
(up) => up.userId === user.userId
);
for (const user of matchedUsers) {
const existingUserPolicy = existingUserPoliciesList.find(
(up) => up.userId === user.userId
);
if (!existingUserPolicy) {
await trx.insert(userPolicies).values({
userId: user.userId,
resourcePolicyId: policyId
});
if (!existingUserPolicy) {
await trx.insert(userPolicies).values({
userId: user.userId,
resourcePolicyId: policyId
});
}
}
}
+29 -17
View File
@@ -22,7 +22,7 @@ import { idpExistsForOrg } from "@server/lib/idp/idpExistsForOrg";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "../billing/tierMatrix";
import { findOrgUserByIdentifier } from "./findOrgUser";
import { findOrgUsersByIdentifier } from "./findOrgUser";
export type ResourcePoliciesResults = {
resourcePolicyId: number;
@@ -467,24 +467,30 @@ async function syncUserPolicies(
.where(eq(userPolicies.resourcePolicyId, policyId));
for (const username of ssoUsers) {
const user = await findOrgUserByIdentifier(trx, orgId, username);
const matchedUsers = await findOrgUsersByIdentifier(
trx,
orgId,
username
);
if (!user) {
if (matchedUsers.length === 0) {
logger.warn(
`User '${username}' not found in org '${orgId}', skipping`
);
continue;
}
const alreadyExists = existingUserPolicies.some(
(up) => up.userId === user.userId
);
for (const user of matchedUsers) {
const alreadyExists = existingUserPolicies.some(
(up) => up.userId === user.userId
);
if (!alreadyExists) {
await trx.insert(userPolicies).values({
userId: user.userId,
resourcePolicyId: policyId
});
if (!alreadyExists) {
await trx.insert(userPolicies).values({
userId: user.userId,
resourcePolicyId: policyId
});
}
}
}
@@ -527,19 +533,25 @@ async function addUserPolicies(
trx: Transaction
) {
for (const username of ssoUsers) {
const user = await findOrgUserByIdentifier(trx, orgId, username);
const matchedUsers = await findOrgUsersByIdentifier(
trx,
orgId,
username
);
if (!user) {
if (matchedUsers.length === 0) {
logger.warn(
`User '${username}' not found in org '${orgId}', skipping`
);
continue;
}
await trx.insert(userPolicies).values({
userId: user.userId,
resourcePolicyId: policyId
});
for (const user of matchedUsers) {
await trx.insert(userPolicies).values({
userId: user.userId,
resourcePolicyId: policyId
});
}
}
}
+27 -1
View File
@@ -29,8 +29,34 @@ export const SiteSchema = z.object({
"docker-socket-enabled": z.boolean().optional().default(true)
});
// A malformed hostname (e.g. stray whitespace) is silently accepted here but
// fails to parse as a URL when newt builds the health check request, which
// takes the target out of the routing pool and breaks the resource entirely
// (see #3677). Validate eagerly so blueprints reject it up front instead.
const healthCheckHostnameSchema = z
.string()
.trim()
.min(1)
.refine((val) => !/\s/.test(val), {
message: "Hostname must not contain whitespace"
})
.refine(
(val) => {
if (z.union([z.ipv4(), z.ipv6()]).safeParse(val).success) {
return true;
}
const hostnameRegex =
/^(?:[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])?$/;
return hostnameRegex.test(val);
},
{
message:
"Hostname must be a valid IP address or hostname (no spaces or invalid characters)"
}
);
export const TargetHealthCheckSchema = z.object({
hostname: z.string(),
hostname: healthCheckHostnameSchema,
port: z.int().min(1).max(65535),
enabled: z.boolean().optional().default(true),
path: z.string().optional().default("/"),
+5
View File
@@ -112,6 +112,11 @@ export class Config {
? "true"
: "false";
process.env.FLAGS_DISABLE_VIRTUAL_API_KEYS_UI = parsedConfig.flags
?.disable_virtual_api_keys_ui
? "true"
: "false";
this.rawConfig = parsedConfig;
}
+1 -1
View File
@@ -71,7 +71,7 @@ export async function withRetry<T>(
const jitter = Math.random() * baseDelay;
const delay = baseDelay + jitter;
logger.warn(
`Transient DB error in ${context}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms`,
`Transient DB issue in ${context}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms`,
{ code: error?.code ?? error?.cause?.code }
);
await new Promise((resolve) => setTimeout(resolve, delay));
+4 -3
View File
@@ -1,5 +1,5 @@
import fs from "fs";
import yaml from "js-yaml";
import * as yaml from "js-yaml";
import { configFilePath1, configFilePath2 } from "./consts";
import { z } from "zod";
import stoi from "./stoi";
@@ -348,8 +348,8 @@ export const configSchema = z
.optional()
.pipe(z.string())
.transform((url) => url.toLowerCase()),
subnet_group: z.string().optional().default("100.89.137.0/20"),
block_size: z.number().positive().gt(0).optional().default(24),
subnet_group: z.string().optional().default("100.89.137.0/18"),
block_size: z.number().positive().gt(0).optional().default(22),
site_block_size: z
.number()
.positive()
@@ -442,6 +442,7 @@ export const configSchema = z
disable_config_managed_domains: z.boolean().optional(),
disable_product_help_banners: z.boolean().optional(),
disable_enterprise_features: z.boolean().optional(),
disable_virtual_api_keys_ui: z.boolean().optional(),
enable_acme_cert_sync: z.boolean().optional().default(true),
disable_private_http_placeholder: z
.boolean()
+72
View File
@@ -0,0 +1,72 @@
import logger from "@server/logger";
export async function withRetry<T>(
fn: () => Promise<T>,
options: {
retries?: number;
baseDelayMs?: number;
label?: string;
// Called with each caught error to decide whether it's worth
// retrying. Defaults to retrying everything (existing behavior) -
// pass this to exclude errors that are known to be permanent (e.g.
// an upstream rate limit or validation rejection) rather than
// transient, so they fail fast instead of wasting retry attempts.
shouldRetry?: (error: unknown) => boolean;
} = {}
): Promise<T> {
const {
retries = 3,
baseDelayMs = 250,
label = "operation",
shouldRetry = () => true
} = options;
let attempt = 0;
while (true) {
try {
return await fn();
} catch (error) {
attempt++;
if (attempt > retries || !shouldRetry(error)) {
throw error;
}
// Exponential backoff with jitter so retries don't all land at once.
const delay =
baseDelayMs * 2 ** (attempt - 1) * (0.5 + Math.random());
logger.warn(
`${label} failed (attempt ${attempt}/${retries + 1}), retrying in ${delay.toFixed(0)}ms`,
error
);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
// Bounds an operation that has no timeout of its own (e.g. acme-client's
// axios instance never sets one, so a stalled TCP connection to the ACME
// server hangs forever instead of erroring). Without this, a single hung
// call can leave its caller's promise permanently unsettled - fatal for
// code that gates future work on that promise resolving, like the
// scheduler's runExclusive() waiting on a batch's Promise.all.
export async function withTimeout<T>(
promise: Promise<T>,
ms: number,
label = "operation"
): Promise<T> {
let timer: NodeJS.Timeout;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error(`${label} timed out after ${ms}ms`)),
ms
);
});
try {
return await Promise.race([promise, timeout]);
} finally {
clearTimeout(timer!);
}
}
+1 -1
View File
@@ -358,7 +358,7 @@ export class TraefikConfigManager {
this.lastActiveDomains = new Set(domains);
}
if (process.env.USE_PANGOLIN_DNS === "true" && build != "oss") {
if (process.env.CERT_MODE === "pangolin" && build != "oss") {
// Scan current local certificate state
this.lastLocalCertificateState =
await this.scanLocalCertificateState();
+6 -12
View File
@@ -1,7 +1,5 @@
/**
* Build the Host()/HostRegexp() Traefik rule for a resource's domain.
* Wildcard resources match any single subdomain via HostRegexp.
*/
// Build the Host()/HostRegexp() Traefik rule for a resource's domain.
// Wildcard resources match any single subdomain via HostRegexp.
export function buildHostRule(
fullDomain: string,
wildcard?: boolean | null
@@ -14,10 +12,8 @@ export function buildHostRule(
return `Host(\`${fullDomain}\`)`;
}
/**
* Append a path-matching clause to a Traefik rule based on the resource's
* configured path and pathMatchType.
*/
// Append a path-matching clause to a Traefik rule based on the resource's
// configured path and pathMatchType.
export function appendPathMatch(
rule: string,
path: string | null | undefined,
@@ -40,10 +36,8 @@ export function appendPathMatch(
return rule;
}
/**
* Compute the router priority for a resource, favoring an explicit override
* and otherwise deriving it from the path match specificity.
*/
// Compute the router priority for a resource, favoring an explicit override
// and otherwise deriving it from the path match specificity.
export function computeRoutePriority(
priority: number | null | undefined,
path: string | null | undefined,
+17
View File
@@ -0,0 +1,17 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
export {
startCertificateManager,
stopCertificateManager
} from "./lib/certificates";
+4
View File
@@ -20,6 +20,8 @@ import { flushSiteBandwidthToDb } from "@server/routers/gerbil/receiveBandwidth"
import { stopPingAccumulator } from "@server/routers/newt/pingAccumulator";
import { shutdownUsageRecorder } from "@server/lib/aiBudgetEnforcement";
import { shutdownAiSessionLogger } from "@server/routers/aiGateway/logAiSession";
import { stopDnsServer } from "./dns";
import { stopCertificateManager } from "./certificates";
async function cleanup() {
await stopPingAccumulator();
@@ -31,6 +33,8 @@ async function cleanup() {
await rateLimitService.cleanup();
await wsCleanup();
await logStreamingManager.shutdown();
await stopDnsServer();
await stopCertificateManager();
process.exit(0);
}
+44
View File
@@ -0,0 +1,44 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { AuthoritativeDNSServer } from "#private/lib/dns";
import { privateConfig } from "#private/lib/config";
let dnsServer: AuthoritativeDNSServer | undefined;
export async function startDnsServer() {
const dnsConfig = privateConfig.getRawPrivateConfig().dns;
if (!dnsConfig || !dnsConfig.enabled) {
return;
}
const cacheOptions = {
stdTTL: 300, // 5 minutes default TTL
checkperiod: 60, // Check for expired keys every 60 seconds
useClones: false // Better performance
};
// Create DNS server
dnsServer = new AuthoritativeDNSServer(dnsConfig.listen_port, cacheOptions);
await dnsServer.start();
}
export async function stopDnsServer() {
if (!dnsServer) {
return;
}
await dnsServer.stop();
dnsServer = undefined;
}
+11 -11
View File
@@ -13,7 +13,7 @@
import NodeCache from "node-cache";
import logger from "@server/logger";
import { redisManager, regionalRedisManager } from "@server/private/lib/redis";
import { redisManager, regionalRedisManager } from "#private/lib/redis";
// Create local cache with maxKeys limit to prevent memory leaks
// With ~10k requests/day and 5min TTL, 10k keys should be more than sufficient
@@ -97,11 +97,11 @@ class AdaptiveCache {
const value = await redisManager.get(key);
if (value !== null) {
logger.debug(`Cache hit in Redis: ${key}`);
// logger.debug(`Cache hit in Redis: ${key}`);
return JSON.parse(value) as T;
}
logger.debug(`Cache miss in Redis: ${key}`);
// logger.debug(`Cache miss in Redis: ${key}`);
return undefined;
} catch (error) {
logger.error(`Redis get error for key ${key}:`, error);
@@ -134,7 +134,7 @@ class AdaptiveCache {
const success = await redisManager.del(k);
if (success) {
deletedCount++;
logger.debug(`Deleted key from Redis: ${k}`);
// logger.debug(`Deleted key from Redis: ${k}`);
}
}
@@ -161,7 +161,7 @@ class AdaptiveCache {
const success = localCache.del(k);
if (success > 0) {
deletedCount++;
logger.debug(`Deleted key from local cache: ${k}`);
// logger.debug(`Deleted key from local cache: ${k}`);
}
}
@@ -229,7 +229,7 @@ class AdaptiveCache {
}
localCache.flushAll();
logger.debug("Flushed local cache");
// logger.debug("Flushed local cache");
}
/**
@@ -332,7 +332,7 @@ class RegionalAdaptiveCache {
redisTtl
);
if (success) {
logger.debug(`[regional] Set key in Redis: ${key}`);
// logger.debug(`[regional] Set key in Redis: ${key}`);
return true;
}
} catch (error) {
@@ -353,10 +353,10 @@ class RegionalAdaptiveCache {
try {
const value = await regionalRedisManager.get(key);
if (value !== null) {
logger.debug(`[regional] Cache hit in Redis: ${key}`);
// logger.debug(`[regional] Cache hit in Redis: ${key}`);
return JSON.parse(value) as T;
}
logger.debug(`[regional] Cache miss in Redis: ${key}`);
// logger.debug(`[regional] Cache miss in Redis: ${key}`);
return undefined;
} catch (error) {
logger.error(
@@ -385,7 +385,7 @@ class RegionalAdaptiveCache {
const success = await regionalRedisManager.del(k);
if (success) {
deletedCount++;
logger.debug(`[regional] Deleted key from Redis: ${k}`);
// logger.debug(`[regional] Deleted key from Redis: ${k}`);
}
}
if (deletedCount === keys.length) return deletedCount;
@@ -400,7 +400,7 @@ class RegionalAdaptiveCache {
const count = regionalLocalCache.del(k);
if (count > 0) {
deletedCount++;
logger.debug(`[regional] Deleted key from local cache: ${k}`);
// logger.debug(`[regional] Deleted key from local cache: ${k}`);
}
}
return deletedCount;
@@ -0,0 +1,298 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import * as acme from "acme-client";
import * as fs from "fs";
import { eq } from "drizzle-orm/sql";
import { privateConfig as config } from "#private/lib/config";
import { DnsChallenge, db, dnsChallenge } from "@server/db";
import { withRetry } from "@server/lib/retry";
import logger from "@server/logger";
import { acmeRateLimiter } from "./acmeRateLimiter";
// acme-client's own retry/backoff logging (429 retries, 5xx retries, each
// status-poll tick in waitForValidStatus) is a no-op by default - it only
// activates via DEBUG=acme-client or this call, neither of which was wired
// up. Without it, a cert silently retrying a Let's Encrypt rate limit for
// several minutes is indistinguishable in our logs from one that's actually
// hung, since our own logging only wraps the call, not what happens inside
// it. Must run before any AcmeClient method is called.
acme.setLogger((msg: string) => logger.info(`[acme-client] ${msg}`));
// acme-client's axios retry wrapper treats any response-less request error
// (timeout, connection reset, DNS blip reaching the ACME server) as
// retryable, but once its internal retries are exhausted it falls through to
// `validateStatus(response)` with `response` still undefined, throwing this
// uninformative TypeError instead of the real network error.
// https://github.com/publishlab/node-acme-client/blob/master/src/axios.js
function isUnresponsiveAcmeError(error: unknown): boolean {
return (
error instanceof TypeError &&
error.message ===
"Cannot read properties of undefined (reading 'config')"
);
}
function normalizeAcmeError(error: unknown): Error {
if (isUnresponsiveAcmeError(error)) {
return new Error(
"ACME server did not respond after repeated attempts (network error reaching the ACME endpoint)",
{ cause: error }
);
}
return error instanceof Error ? error : new Error(String(error));
}
export class AcmeClientManager {
private client: acme.Client | null = null;
private accountKey: string | null = null;
async initialize() {
try {
this.accountKey = await this.loadAccountKey();
this.client = new acme.Client({
directoryUrl: config.getRawConfig().acme!.acme_directory_url,
accountKey: this.accountKey
});
// Try to create account or get existing one
await this.client.createAccount({
termsOfServiceAgreed: true,
contact: [`mailto:${config.getRawConfig().acme!.contact_email}`]
});
logger.info("ACME client initialized successfully");
} catch (error) {
logger.error("Failed to initialize ACME client:", error);
throw error;
}
}
private async loadAccountKey(): Promise<string> {
const keyPath = config.getRawConfig().acme!.acme_account_key_path;
if (fs.existsSync(keyPath)) {
logger.info("Loading existing account key");
return fs.readFileSync(keyPath, "utf8");
} else {
logger.info("Generating new account key");
const privateKey = await acme.crypto.createPrivateKey();
const privateKeyString = privateKey.toString();
fs.writeFileSync(keyPath, privateKeyString);
return privateKeyString;
}
}
getClient(): acme.Client {
if (!this.client) {
throw new Error("ACME client not initialized");
}
return this.client;
}
async createOrder(domain: string, wildcard: boolean = false): Promise<any> {
const client = this.getClient();
const identifiers = wildcard
? [
{ type: "dns", value: domain },
{ type: "dns", value: `*.${domain}` }
]
: [{ type: "dns", value: domain }];
await acmeRateLimiter.acquire();
const order = await client.createOrder({
identifiers
});
if (wildcard) {
logger.info(`Created wildcard order for domain: ${domain}`);
} else {
logger.info(`Created order for domain: ${domain}`);
}
return order;
}
async getAuthorizations(order: any): Promise<any[]> {
const client = this.getClient();
await acmeRateLimiter.acquire();
return client.getAuthorizations(order);
}
async handleDnsChallenge(
dnsChallenges: {
authz: any;
challenge: any;
}[]
): Promise<void> {
const client = this.getClient();
let challengeDomains: DnsChallenge[] = [];
for (const { authz, challenge } of dnsChallenges) {
const keyAuthorization =
await client.getChallengeKeyAuthorization(challenge);
// Extract the domain from authorization
const domain = authz.identifier.value;
// Store challenge in database for DNS server to pick up
challengeDomains = await withRetry(
() =>
db
.insert(dnsChallenge)
.values({
domain: domain,
token: challenge.token,
keyAuthorization,
createdAt: Math.floor(Date.now() / 1000),
expiresAt: Math.floor(
(Date.now() +
config.getRawConfig().acme!
.challenge_ttl_ms) /
1000
)
})
.returning(),
{ label: `insert dnsChallenge for domain ${domain}` }
);
logger.info(
`DNS challenge stored for domain: ${domain} as token ${challenge.token} and keyAuthorization`
);
}
await new Promise((resolve) => setTimeout(resolve, 2000));
const failedDomains: string[] = [];
for (const { authz, challenge } of dnsChallenges) {
const domain = authz.identifier.value;
const challengeDomain = `_acme-challenge.${domain}`;
try {
// The ACME server occasionally has a transient network blip
// mid-sequence; retry the whole verify/complete/wait sequence
// rather than just the DNS challenge propagation wait, since
// these calls are safe to repeat against the ACME server.
await withRetry(
async () => {
// Verify challenge
await acmeRateLimiter.acquire();
await client.verifyChallenge(authz, challenge);
// Complete challenge
logger.info(
`Completing challenge for domain: ${challengeDomain}`
);
await acmeRateLimiter.acquire();
await client.completeChallenge(challenge);
// Wait for validation
logger.info(
`Waiting for challenge to be validated for domain: ${challengeDomain}...`
);
await acmeRateLimiter.acquire();
await client.waitForValidStatus(challenge);
},
{
retries: 2,
baseDelayMs: 5000,
label: `ACME challenge completion for domain ${domain}`,
// Only retry the known network-blip crash - a
// genuine validation failure (e.g. challenge marked
// "invalid" because the DNS record wasn't found) is
// permanent and should fail immediately instead of
// burning Let's Encrypt's per-hostname failed-
// validation rate limit on retries that can't help.
shouldRetry: isUnresponsiveAcmeError
}
);
logger.info(`Challenge completed for domain: ${domain}`);
} catch (error) {
logger.error(
`Failed to complete challenge for domain ${domain}:`,
normalizeAcmeError(error)
);
failedDomains.push(domain);
}
}
for (const challengeDomain of challengeDomains) {
await this.removeDnsChallenge(challengeDomain.dnsChallengeId);
logger.info(
`Removed DNS challenge for domain: ${challengeDomain.domain}`
);
}
// A failed dns-01 challenge leaves the order stuck in "pending" -
// finalizing it would just fail with a confusing ACME error, so
// stop here and let the caller mark the certificate as failed.
if (failedDomains.length > 0) {
throw new Error(
`DNS-01 challenge validation failed for domain(s): ${failedDomains.join(", ")}`
);
}
}
async removeDnsChallenge(dnsChallengeId: number): Promise<void> {
try {
await withRetry(
() =>
db
.delete(dnsChallenge)
.where(eq(dnsChallenge.dnsChallengeId, dnsChallengeId)),
{ label: `delete dnsChallenge ${dnsChallengeId}` }
);
} catch (error) {
logger.error(
`Failed to clean up DNS challenge for id ${dnsChallengeId}:`,
error
);
}
}
async finalizeCertificate(
order: any,
domain: string,
wildcard: boolean = false
): Promise<{ certificate: string; privateKey: string }> {
const client = this.getClient();
const altNames = wildcard ? [`*.${domain}`, domain] : [domain];
// Create CSR
const [privateKey, csr] = await acme.crypto.createCsr({
altNames
});
// Finalize order
await acmeRateLimiter.acquire();
const finalizedOrder = await client.finalizeOrder(order, csr);
// Get certificate
await acmeRateLimiter.acquire();
const certificate = await client.getCertificate(finalizedOrder);
logger.info(`Certificate obtained for domain: ${domain}`);
return {
certificate: certificate.toString(),
privateKey: privateKey.toString()
};
}
}
export const acmeClientManager = new AcmeClientManager();
@@ -0,0 +1,71 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { privateConfig as config } from "#private/lib/config";
import logger from "@server/logger";
import { redis } from "../redis";
// Caps outgoing ACME API calls to a fixed budget per wall-clock second,
// shared across all pops workers via Redis (mirrors the lockManager pattern
// in @lib/lock) - a per-process limiter wouldn't be enough since multiple
// workers issue certificates against the same Let's Encrypt account.
const ACQUIRE_SCRIPT = `
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local current = redis.call('INCR', key)
if current == 1 then
redis.call('PEXPIRE', key, 2000)
end
if current > limit then
return 0
else
return 1
end
`;
class AcmeRateLimiter {
async acquire(): Promise<void> {
const limit =
config.getRawConfig().acme?.acme_requests_per_second ?? 15;
for (;;) {
const bucket = Math.floor(Date.now() / 1000);
const key = `acme_rate_limit:${bucket}`;
let allowed: number;
try {
allowed = (await redis.eval(
ACQUIRE_SCRIPT,
1,
key,
limit.toString()
)) as number;
} catch (error) {
logger.error(
"ACME rate limiter check failed, proceeding without throttling:",
error
);
return;
}
if (allowed === 1) {
return;
}
// Budget for this second is spent - wait for the next window.
const waitMs = 1000 - (Date.now() % 1000) + 10;
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
}
}
export const acmeRateLimiter = new AcmeRateLimiter();
@@ -0,0 +1,511 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { acmeClientManager } from "./acme-client";
import { dnsValidator } from "./dns-validator";
import { getTableColumns } from "drizzle-orm";
import { eq, and, or, isNull, lt, asc } from "drizzle-orm/sql";
import { config } from "@server/lib/config";
import { db, certificates, domains, Certificate } from "@server/db";
import { encrypt } from "@server/lib/crypto";
import { withTimeout, withRetry } from "@server/lib/retry";
import logger from "@server/logger";
import { lockManager } from "../lock";
import { pushCertUpdateToAffectedNewts } from "@server/lib/acmeCertSync";
import crypto from "crypto";
// Number of on-demand DNS validation attempts made right before a
// certificate is (re)issued, to avoid burning Let's Encrypt rate limits on
// domains whose DNS has drifted since they were last verified.
const PRE_CERT_DNS_VALIDATION_ATTEMPTS = 3;
// Hard ceiling on a single certificate's issuance/renewal flow. acme-client's
// axios instance never sets a request timeout, so a stalled connection to
// the ACME server hangs forever instead of erroring - and since
// processPendingCertificates/processRenewalCandidates gate the *next* batch
// on Promise.all(...) over the current one, one hung certificate would
// otherwise stall every other domain permanently. Sized generously above the
// legitimate worst case (acme-client's own bounded backoff is ~3.6min per
// status-polling loop, and a wildcard cert's two identifiers plus order
// finalization can chain a few of those) so this only fires on a genuine hang.
const CERTIFICATE_ISSUANCE_TIMEOUT_MS = 20 * 60 * 1000;
// "requested" is set the instant a cert starts processing and is never
// queried anywhere else - processPendingCertificates only selects "pending"
// and processRenewalCandidates only selects "valid". So if the *process*
// dies mid-flight (OOM, node eviction, a rolling deploy) rather than just
// hanging, the row is orphaned in "requested" permanently with nothing to
// ever pick it back up, no matter how good the in-process timeouts are.
// Threshold is set comfortably above CERTIFICATE_ISSUANCE_TIMEOUT_MS plus the
// scheduler's own outer backstop so this never reclaims a cert that's still
// genuinely being worked on.
const STUCK_CERTIFICATE_THRESHOLD_MS = 40 * 60 * 1000;
export class CertificateService {
// Runs at the top of every processPendingCertificates tick so an
// interrupted worker's leftovers always get put back in the queue
// instead of sitting invisible to every query forever.
private async reclaimStuckCertificates(): Promise<void> {
const staleBefore =
Math.floor(Date.now() / 1000) -
Math.floor(STUCK_CERTIFICATE_THRESHOLD_MS / 1000);
const reclaimed = await db
.update(certificates)
.set({
status: "pending",
errorMessage:
'Reclaimed after being stuck in "requested" state - the worker processing it likely restarted or crashed',
updatedAt: Math.floor(Date.now() / 1000)
})
.where(
and(
eq(certificates.status, "requested"),
lt(certificates.updatedAt, staleBefore)
)
)
.returning({ domain: certificates.domain });
if (reclaimed.length > 0) {
logger.warn(
`Reclaimed ${reclaimed.length} certificate(s) stuck in "requested" state: ${reclaimed
.map((c) => c.domain)
.join(", ")}`
);
}
}
async processPendingCertificates(): Promise<void> {
logger.debug("Checking for pending certificates...");
await this.reclaimStuckCertificates();
const pendingCerts = await db
.select(getTableColumns(certificates))
.from(certificates)
.leftJoin(domains, eq(certificates.domainId, domains.domainId))
.where(
and(
eq(certificates.status, "pending"),
or(
// Certs with no linked domain row (e.g. legacy certs
// imported from acme.json) aren't gated on domain
// verification since there's nothing to check.
isNull(certificates.domainId),
and(
eq(domains.verified, true),
eq(domains.failed, false)
)
)
)
)
.limit(10);
if (pendingCerts.length === 0) {
logger.debug("No pending certificates found");
return;
}
logger.info(`Found ${pendingCerts.length} pending certificates`);
// Process the batch concurrently so one domain stuck retrying a slow
// DNS-01 challenge (the ACME client's waitForValidStatus can spend
// minutes on a bad domain) doesn't stall the rest of the batch.
// processSingleCertificate catches its own errors and each cert uses
// an independent per-domain lock, so this is safe to parallelize.
await Promise.all(
pendingCerts.map((cert) => this.processSingleCertificate(cert))
);
}
async processRenewalCandidates(): Promise<void> {
logger.debug("Checking for certificates needing renewal...");
const now = Math.floor(Date.now() / 1000);
const renewalCandidates = await db
.select(getTableColumns(certificates))
.from(certificates)
.leftJoin(domains, eq(certificates.domainId, domains.domainId))
.where(
and(
eq(certificates.status, "valid"),
lt(certificates.expiresAt, now + 15 * 24 * 60 * 60), // 15 days from now
or(
// Certs with no linked domain row (e.g. legacy certs
// imported from acme.json) aren't gated on domain
// verification since there's nothing to check.
isNull(certificates.domainId),
and(
eq(domains.verified, true),
eq(domains.failed, false)
)
)
)
)
// Most urgent first, so already-expired certs aren't starved
// behind the limit by certs that still have weeks of runway.
.orderBy(asc(certificates.expiresAt))
.limit(50);
if (renewalCandidates.length === 0) {
logger.debug("No certificates need renewal");
return;
}
logger.info(
`Found ${renewalCandidates.length} certificates needing renewal`
);
for (const cert of renewalCandidates) {
if (cert.expiresAt !== null && cert.expiresAt < now) {
logger.warn(
`Certificate for ${cert.domain} is marked "valid" but already expired at ${new Date(cert.expiresAt * 1000).toISOString()} (bad state) - renewing immediately`
);
}
}
// Process the batch concurrently - see processPendingCertificates for why.
await Promise.all(
renewalCandidates.map((cert) => this.renewCertificate(cert))
);
}
private async processSingleCertificate(cert: Certificate): Promise<void> {
const lockKey = `cert:${cert.domain}`;
const lockToken = await lockManager.acquireLock(lockKey);
if (!lockToken) {
logger.debug(
`Could not acquire lock for certificate: ${cert.domain}`
);
return;
}
try {
logger.info(`Processing certificate for domain: ${cert.domain}`);
// Update status to processing
await db
.update(certificates)
.set({
status: "requested",
updatedAt: Math.floor(Date.now() / 1000)
})
.where(eq(certificates.certId, cert.certId));
//
await withTimeout(
this.obtainCertificate(cert),
CERTIFICATE_ISSUANCE_TIMEOUT_MS,
`certificate issuance for ${cert.domain}`
);
} catch (error) {
logger.error(
`Failed to process certificate for ${cert.domain}:`,
error
);
await db
.update(certificates)
.set({
status: "failed",
errorMessage:
error instanceof Error
? error.message
: "Unknown error",
updatedAt: Math.floor(Date.now() / 1000)
})
.where(eq(certificates.certId, cert.certId));
} finally {
await lockManager.releaseLock(lockKey, lockToken);
}
}
private async renewCertificate(cert: Certificate): Promise<void> {
const lockKey = `cert:${cert.domain}`;
const lockToken = await lockManager.acquireLock(lockKey);
if (!lockToken) {
logger.debug(
`Could not acquire lock for certificate renewal: ${cert.domain}`
);
return;
}
try {
logger.info(`Renewing certificate for domain: ${cert.domain}`);
// Update last renewal attempt
await db
.update(certificates)
.set({
lastRenewalAttempt: Math.floor(Date.now() / 1000),
updatedAt: Math.floor(Date.now() / 1000)
})
.where(eq(certificates.certId, cert.certId));
await withTimeout(
this.obtainCertificate(cert),
CERTIFICATE_ISSUANCE_TIMEOUT_MS,
`certificate renewal for ${cert.domain}`
);
} catch (error) {
logger.error(
`Failed to renew certificate for ${cert.domain}:`,
error
);
await db
.update(certificates)
.set({
status: "failed",
errorMessage:
error instanceof Error
? error.message
: "Unknown error",
lastRenewalAttempt: Math.floor(Date.now() / 1000),
updatedAt: Math.floor(Date.now() / 1000)
})
.where(eq(certificates.certId, cert.certId));
} finally {
await lockManager.releaseLock(lockKey, lockToken);
}
}
// Re-checks the domain's DNS records right before we spend a Let's
// Encrypt order on it, so drift that happened after the domain was
// originally verified doesn't burn ACME rate limits. Certs with no
// linked domain row (e.g. legacy/manually-managed certs) skip this and
// proceed as before, since there are no tracked DNS records to check.
private async verifyDomainBeforeIssuance(cert: Certificate): Promise<void> {
if (!cert.domainId) {
return;
}
const [domain] = await db
.select()
.from(domains)
.where(eq(domains.domainId, cert.domainId))
.limit(1);
if (!domain) {
return;
}
for (
let attempt = 1;
attempt <= PRE_CERT_DNS_VALIDATION_ATTEMPTS;
attempt++
) {
// Offset `tries` so each attempt round-robins to a different
// privateConfigured DNS resolver instead of re-querying the same one.
const probe = { ...domain, tries: domain.tries + attempt - 1 };
if (
await dnsValidator.validateDomain(probe, {
forceRecheck: true
})
) {
await db
.update(domains)
.set({ verified: true, failed: false, errorMessage: null })
.where(eq(domains.domainId, domain.domainId));
return;
}
logger.warn(
`Pre-certificate DNS check ${attempt}/${PRE_CERT_DNS_VALIDATION_ATTEMPTS} failed for domain ${domain.baseDomain} (cert: ${cert.domain})`
);
}
const errorMessage = `Domain failed DNS validation ${PRE_CERT_DNS_VALIDATION_ATTEMPTS} times before certificate issuance`;
await db
.update(domains)
.set({ verified: false, failed: true, errorMessage })
.where(eq(domains.domainId, domain.domainId));
throw new Error(errorMessage);
}
private async obtainCertificate(cert: Certificate): Promise<void> {
await this.verifyDomainBeforeIssuance(cert);
// Create order
const order = await acmeClientManager.createOrder(
cert.domain,
cert.wildcard || false
);
// Update with order ID
await withRetry(
() =>
db
.update(certificates)
.set({
orderId: order.url,
updatedAt: Math.floor(Date.now() / 1000)
})
.where(eq(certificates.certId, cert.certId)),
{ label: `update orderId for certificate ${cert.domain}` }
);
// Get authorizations
const authorizations = await acmeClientManager.getAuthorizations(order);
// Aggregate all DNS-01 challenges
const dnsChallenges = authorizations.map((authz: any) => {
const dnsChallenge = authz.challenges.find(
(c: any) => c.type === "dns-01"
);
if (!dnsChallenge) {
throw new Error(
`No DNS-01 challenge found for ${authz.identifier.value}`
);
}
return {
authz,
challenge: dnsChallenge
};
});
// Send all DNS-01 challenges in one request to handleDnsChallenge
await acmeClientManager.handleDnsChallenge(dnsChallenges);
// Finalize certificate
const { certificate, privateKey } =
await acmeClientManager.finalizeCertificate(
order,
cert.domain,
cert.wildcard || false
);
const encryptionKey = config.getRawConfig().server.secret;
if (!encryptionKey) {
throw new Error("Encryption key not provided");
}
// Encrypt certificate and private key
const encryptedCert = encrypt(certificate, encryptionKey);
const encryptedKey = encrypt(privateKey, encryptionKey);
// Parse certificate to get expiration date
const expiresAt = this.extractExpirationDate(certificate);
// Update database record. This persists the certificate we just
// obtained from the ACME server, so it's retried aggressively -
// losing this write means re-issuing the cert from scratch.
await withRetry(
() =>
db
.update(certificates)
.set({
status: "valid",
expiresAt: Math.floor(expiresAt.getTime() / 1000),
renewalCount: (cert.renewalCount || 0) + 1,
errorMessage: null,
updatedAt: Math.floor(Date.now() / 1000),
certFile: encryptedCert,
keyFile: encryptedKey
})
.where(eq(certificates.certId, cert.certId)),
{
retries: 5,
label: `persist issued certificate for ${cert.domain}`
}
);
logger.info(
`Certificate successfully obtained/renewed for domain: ${cert.domain}`
);
await pushCertUpdateToAffectedNewts(
cert.domain,
cert.domainId ?? null,
certificate,
privateKey
);
}
private extractExpirationDate(certificate: string): Date {
try {
// Extract the certificate block
const pem = certificate
.replace(/-----BEGIN CERTIFICATE-----/g, "")
.replace(/-----END CERTIFICATE-----/g, "")
.replace(/\s+/g, "");
const der = Buffer.from(pem, "base64");
// Use Node.js crypto to parse the certificate
const x509 = new crypto.X509Certificate(der);
return new Date(x509.validTo);
} catch (error) {
logger.warn(
"Failed to parse certificate expiration date, using default",
error
);
// Default to 90 days from now (Let's Encrypt default)
return new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
}
}
async addCertificateRequest(domain: string): Promise<void> {
try {
await db.insert(certificates).values({
domain,
status: "pending",
createdAt: Math.floor(Date.now() / 1000),
updatedAt: Math.floor(Date.now() / 1000)
});
logger.info(`Certificate request added for domain: ${domain}`);
} catch (error) {
if (error instanceof Error && error.message.includes("unique")) {
logger.warn(
`Certificate request already exists for domain: ${domain}`
);
} else {
throw error;
}
}
}
async getCertificateStatus(domain: string) {
const cert = await db
.select()
.from(certificates)
.where(eq(certificates.domain, domain))
.limit(1);
return cert[0] || null;
}
async cleanupExpiredChallenges(): Promise<void> {
try {
const result = await db
.delete(certificates)
.where(
lt(certificates.expiresAt, Math.floor(Date.now() / 1000))
)
.returning();
if (result.length > 0) {
logger.info(
`Cleaned up ${result.length} expired DNS challenges`
);
}
} catch (error) {
logger.error("Failed to cleanup expired challenges:", error);
}
}
}
export const certificateService = new CertificateService();
@@ -0,0 +1,334 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { eq, and, lt } from "drizzle-orm";
import * as dns from "dns/promises";
import { privateConfig as config } from "#private/lib/config";
import { db, domains, DnsRecord, dnsRecords, Domain } from "@server/db";
import logger from "@server/logger";
import { lockManager } from "../lock";
export const DNS_VALIDATOR_MAX_TRIES = 300;
export class DNSValidator {
private static readonly MAX_TRIES = DNS_VALIDATOR_MAX_TRIES;
constructor() {}
async validateAll(): Promise<void> {
// Get all domains that are not yet verified and haven't exceeded max tries
const unverifiedDomains: Domain[] = await db
.select()
.from(domains)
.where(
and(
eq(domains.verified, false),
lt(domains.tries, DNSValidator.MAX_TRIES)
)
);
if (unverifiedDomains.length === 0) {
logger.debug("No unverified domains found for DNS validation");
return;
}
logger.info(`Validating ${unverifiedDomains.length} DNS records`);
for (const domain of unverifiedDomains) {
const lockKey = `dns:${domain.baseDomain}`;
const lockToken = await lockManager.acquireLock(lockKey);
if (!lockToken) {
logger.debug(
`Could not acquire lock for DNS validation: ${domain.baseDomain}`
);
continue;
}
try {
const isValid = await this.validateDomain(domain);
if (isValid) {
await db
.update(domains)
.set({
verified: true,
failed: false,
tries: 0,
errorMessage: null
})
.where(eq(domains.domainId, domain.domainId));
logger.info(
`Domain ${domain.baseDomain} validated successfully`
);
} else {
const newTries = domain.tries + 1;
const shouldMarkAsFailed =
newTries >= DNSValidator.MAX_TRIES;
await db
.update(domains)
.set({
tries: newTries,
failed: shouldMarkAsFailed
})
.where(eq(domains.domainId, domain.domainId));
if (shouldMarkAsFailed) {
logger.warn(
`Domain ${domain.baseDomain} exceeded maximum tries (${DNSValidator.MAX_TRIES}), marking as failed`
);
} else {
logger.debug(
`Domain ${domain.baseDomain} did not validate (attempt ${newTries}/${DNSValidator.MAX_TRIES})`
);
}
}
} catch (err) {
logger.warn(
`Error validating domain ${domain.baseDomain}:`,
err
);
// Increment tries even on error
const newTries = domain.tries + 1;
const shouldMarkAsFailed = newTries >= DNSValidator.MAX_TRIES;
await db
.update(domains)
.set({
tries: newTries,
failed: shouldMarkAsFailed
})
.where(eq(domains.domainId, domain.domainId));
} finally {
await lockManager.releaseLock(lockKey, lockToken);
}
}
}
async validateDomain(
domain: Domain,
opts: { forceRecheck?: boolean } = {}
): Promise<boolean> {
const { forceRecheck = false } = opts;
const resolver = new dns.Resolver();
const servers = config.getRawConfig().acme?.dns_resolvers;
if (!servers || servers.length === 0) {
throw new Error("No DNS resolvers configured");
}
const dnsServer = servers[domain.tries % servers.length]!;
resolver.setServers([dnsServer]);
logger.debug(
`Using DNS server ${dnsServer} for domain ${domain.baseDomain} (try ${domain.tries})`
);
// Get all DNS records for this domain
const records: DnsRecord[] = await db
.select()
.from(dnsRecords)
.where(eq(dnsRecords.domainId, domain.domainId));
if (records.length === 0) {
logger.warn(`No DNS records found for domain ${domain.baseDomain}`);
return false;
}
if (!forceRecheck && records.every((r) => r.verified)) {
logger.info(
`All DNS records already verified for domain ${domain.baseDomain}`
);
return true;
}
logger.info(
`Validating ${records.length} DNS records for domain ${domain.baseDomain}`
);
// Collect the full set of expected NS values for this domain so we can
// detect extra records that are present in DNS but not in our DB.
const expectedNsValues = new Set<string>(
records.filter((r) => r.recordType === "NS").map((r) => r.value)
);
// Cache resolved NS records across iterations — there will be 3 NS
// records in the DB and we don't need to hit the upstream server 3 times.
let previousNs: string[] | null = null;
for (const record of records) {
// Skip already verified records, unless a live recheck was requested
if (record.verified && !forceRecheck) {
continue;
}
let isValid = false;
try {
if (record.recordType === "NS") {
let nsRecords: string[] | null = previousNs;
if (!nsRecords) {
nsRecords = await resolver.resolveNs(
record.baseDomain || domain.baseDomain
);
}
logger.info(
`NS records for ${
record.baseDomain || domain.baseDomain
}:`,
nsRecords
);
// Check if this expected NS value is present in the live records.
// A stale/legacy expected value (e.g. left over from a
// nameserver rebrand) is also accepted as long as the live
// records resolve to some other known-valid nameserver —
// the specific literal hostname stored per-domain isn't
// meaningful once it's a recognized alias.
isValid = nsRecords.some((ns) => ns === record.value);
previousNs = nsRecords;
} else if (record.recordType === "CNAME") {
const cnameRecords = await resolver.resolveCname(
record.baseDomain || domain.baseDomain
);
logger.info(
`CNAME records for ${
record.baseDomain || domain.baseDomain
}:`,
cnameRecords
);
// Check if the CNAME record matches the expected value
isValid =
cnameRecords.length === 1 &&
cnameRecords[0] === record.value;
} else if (record.recordType === "TXT") {
const txtRecords = await resolver.resolveTxt(
record.baseDomain || domain.baseDomain
);
logger.info(
`TXT records for ${
record.baseDomain || domain.baseDomain
}:`,
txtRecords
);
// TXT records come as an array of arrays, flatten and check
const flatTxtRecords = txtRecords.flat();
isValid = flatTxtRecords.includes(record.value);
} else if (record.recordType === "A") {
const aRecords = await resolver.resolve4(
record.baseDomain || domain.baseDomain
);
logger.info(
`A records for ${
record.baseDomain || domain.baseDomain
}:`,
aRecords
);
// Check if the A record matches the expected value
isValid = aRecords.includes(record.value);
} else {
logger.warn(
`Unsupported record type: ${record.recordType}`
);
continue;
}
} catch (error) {
isValid = false;
logger.debug(
`Did not resolve ${record.recordType} record for ${
record.baseDomain || domain.baseDomain
}:`,
error
);
}
// Update the individual record verification status. Runs for
// both a mismatched value and a failed/thrown DNS lookup, so a
// previously-verified record that stops resolving (e.g. NXDOMAIN
// after NS delegation is dropped) gets downgraded instead of
// leaving stale `verified: true` state behind.
if (isValid) {
await db
.update(dnsRecords)
.set({ verified: true })
.where(eq(dnsRecords.id, record.id));
logger.info(
`DNS record ${record.id} (${record.recordType}) for ${
record.baseDomain || domain.baseDomain
} verified successfully`
);
} else {
if (record.verified) {
await db
.update(dnsRecords)
.set({ verified: false })
.where(eq(dnsRecords.id, record.id));
}
logger.debug(
`DNS record ${record.id} (${record.recordType}) for ${
record.baseDomain || domain.baseDomain
} does not match expected value: ${record.value}`
);
}
}
// --- Extra NS record check ---
// If we resolved NS records during this pass, verify that the live DNS
// has no nameservers beyond the ones we expect. Individual records may
// already be marked verified above, but we must block full domain
// verification until the extra records are removed.
if (previousNs !== null && expectedNsValues.size > 0) {
const extraNsRecords = previousNs.filter(
(ns) => !expectedNsValues.has(ns)
);
if (extraNsRecords.length > 0) {
const errorMessage = `Extra NS records found that are not expected: ${extraNsRecords.join(", ")}. Remove these nameservers to complete domain verification.`;
await db
.update(domains)
.set({ errorMessage })
.where(eq(domains.domainId, domain.domainId));
logger.warn(
`Domain ${domain.baseDomain} has extra NS records that prevent verification: ${extraNsRecords.join(", ")}`
);
return false;
}
// No extras — clear any stale error that was previously written
await db
.update(domains)
.set({ errorMessage: null })
.where(eq(domains.domainId, domain.domainId));
}
// Check if all records are now verified
const updatedRecords: DnsRecord[] = await db
.select()
.from(dnsRecords)
.where(eq(dnsRecords.domainId, domain.domainId));
const allRecordsVerified = updatedRecords.every((r) => r.verified);
logger.info(
`Domain ${domain.baseDomain}: ${
updatedRecords.filter((r) => r.verified).length
}/${updatedRecords.length} records verified`
);
return allRecordsVerified;
}
}
export const dnsValidator = new DNSValidator();
@@ -0,0 +1,233 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { eq, and, or, isNull, lt } from "drizzle-orm";
import * as dns from "dns/promises";
import { DNS_VALIDATOR_MAX_TRIES } from "./dns-validator";
import { db, domains, DnsRecord, dnsRecords, Domain } from "@server/db";
import logger from "@server/logger";
import { lockManager } from "../lock";
import { privateConfig as config } from "#private/lib/config";
// Module-level counter so successive domains in a batch round-robin across servers.
let serverIndex = 0;
export class DomainReverifier {
async reverifyAll(): Promise<void> {
const certConfig = config.getRawConfig().acme;
if (!certConfig) {
logger.debug(
"No certificate config — skipping domain reverification"
);
return;
}
const windowMs = certConfig.domain_reverification_window_ms;
const batchSize = certConfig.domain_reverification_batch_size;
const windowSecs = Math.floor(windowMs / 1000);
const cutoff = Math.floor(Date.now() / 1000) - windowSecs;
const domainsToCheck: Domain[] = await db
.select()
.from(domains)
.where(
and(
eq(domains.verified, true),
or(
isNull(domains.lastCheckedAt),
lt(domains.lastCheckedAt, cutoff)
)
)
)
.limit(batchSize);
if (domainsToCheck.length === 0) {
logger.debug("No verified domains due for reverification");
return;
}
logger.info(`Reverifying ${domainsToCheck.length} domains`);
for (const domain of domainsToCheck) {
const lockKey = `dns-reverify:${domain.baseDomain}`;
const lockToken = await lockManager.acquireLock(lockKey);
if (!lockToken) {
logger.debug(
`Could not acquire lock for domain reverification: ${domain.baseDomain}`
);
continue;
}
try {
await this.reverifyDomain(domain, certConfig.dns_resolvers);
} catch (err) {
logger.warn(
`Unexpected error reverifying domain ${domain.baseDomain}:`,
err
);
// Still stamp lastCheckedAt so we don't hammer a broken domain every run.
await db
.update(domains)
.set({ lastCheckedAt: Math.floor(Date.now() / 1000) })
.where(eq(domains.domainId, domain.domainId));
} finally {
await lockManager.releaseLock(lockKey, lockToken);
}
}
}
private async reverifyDomain(
domain: Domain,
servers: string[]
): Promise<void> {
if (!servers || servers.length === 0) {
throw new Error("No DNS resolvers configured");
}
// Round-robin across servers; advance the global counter so the next
// domain in the same batch gets a different server.
const dnsServer = servers[serverIndex % servers.length]!;
serverIndex++;
const resolver = new dns.Resolver();
resolver.setServers([dnsServer]);
logger.debug(
`Reverifying domain ${domain.baseDomain} using DNS server ${dnsServer}`
);
const records: DnsRecord[] = await db
.select()
.from(dnsRecords)
.where(eq(dnsRecords.domainId, domain.domainId));
if (records.length === 0) {
logger.warn(
`No DNS records found for domain ${domain.baseDomain} during reverification — marking failed`
);
await this.markFailed(
domain.domainId,
"No DNS records found during periodic reverification"
);
return;
}
const expectedNsValues = new Set<string>(
records.filter((r) => r.recordType === "NS").map((r) => r.value)
);
let allValid = true;
let errorMessage: string | null = null;
let resolvedNs: string[] | null = null;
for (const record of records) {
let isValid = false;
try {
if (record.recordType === "NS") {
if (!resolvedNs) {
resolvedNs = await resolver.resolveNs(
record.baseDomain || domain.baseDomain
);
}
isValid = resolvedNs.some((ns) => ns === record.value);
} else if (record.recordType === "CNAME") {
const cnameRecords = await resolver.resolveCname(
record.baseDomain || domain.baseDomain
);
isValid =
cnameRecords.length === 1 &&
cnameRecords[0] === record.value;
} else if (record.recordType === "TXT") {
const txtRecords = await resolver.resolveTxt(
record.baseDomain || domain.baseDomain
);
isValid = txtRecords.flat().includes(record.value);
} else if (record.recordType === "A") {
const aRecords = await resolver.resolve4(
record.baseDomain || domain.baseDomain
);
isValid = aRecords.includes(record.value);
} else {
logger.warn(
`Unsupported record type ${record.recordType} during reverification of ${domain.baseDomain}`
);
continue;
}
} catch (err) {
logger.debug(
`DNS lookup failed for ${record.recordType} record on ${record.baseDomain || domain.baseDomain}:`,
err
);
isValid = false;
}
if (!isValid) {
allValid = false;
errorMessage = `${record.recordType} record for ${record.baseDomain || domain.baseDomain} no longer resolves to expected value "${record.value}"`;
break;
}
}
// Check for extra NS records beyond what we expect.
if (allValid && resolvedNs !== null && expectedNsValues.size > 0) {
const extraNs = resolvedNs.filter(
(ns) => !expectedNsValues.has(ns)
);
if (extraNs.length > 0) {
allValid = false;
errorMessage = `Extra NS records found: ${extraNs.join(", ")}. Remove these nameservers.`;
}
}
const now = Math.floor(Date.now() / 1000);
if (allValid) {
await db
.update(domains)
.set({ lastCheckedAt: now, errorMessage: null })
.where(eq(domains.domainId, domain.domainId));
logger.debug(
`Domain ${domain.baseDomain} passed periodic reverification`
);
} else {
await this.markFailed(domain.domainId, errorMessage);
logger.warn(
`Domain ${domain.baseDomain} failed periodic reverification: ${errorMessage}`
);
}
}
private async markFailed(
domainId: string,
errorMessage: string | null
): Promise<void> {
await db
.update(domains)
.set({
verified: false,
failed: true,
// Three below MAX_TRIES: keeps the domain out of the DNS
// validator's immediate retry loop, while still leaving it
// eligible (tries < MAX_TRIES) for a few more validation
// passes instead of being excluded forever once tries hits
// MAX_TRIES.
tries: DNS_VALIDATOR_MAX_TRIES - 3,
lastCheckedAt: Math.floor(Date.now() / 1000),
errorMessage
})
.where(eq(domains.domainId, domainId));
}
}
export const domainReverifier = new DomainReverifier();
+45
View File
@@ -0,0 +1,45 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import logger from "@server/logger";
import { privateConfig } from "#private/lib/config";
import { acmeClientManager } from "./acme-client";
import { jobScheduler } from "./scheduler";
export async function startCertificateManager() {
const acmeConfig = privateConfig.getRawPrivateConfig().acme;
if (
acmeConfig &&
acmeConfig.cert_mode === "pangolin" &&
acmeConfig.enable_acme_client
) {
logger.info("Starting certificate management server...");
// Initialize ACME client
await acmeClientManager.initialize();
// Start certificate issuance/renewal jobs
await jobScheduler.start();
}
if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
// DNS record validation/reverification doesn't require certs, so it
// runs whenever Pangolin is acting as the authoritative DNS server,
// independent of the cert manager above.
await jobScheduler.startDnsJobs();
}
}
export async function stopCertificateManager() {
await jobScheduler.stop();
}
@@ -0,0 +1,190 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { withTimeout } from "@server/lib/retry";
import logger from "@server/logger";
import { certificateService } from "./certificate-service";
import { privateConfig as config } from "#private/lib/config";
import { dnsValidator } from "./dns-validator";
import { domainReverifier } from "./domain-reverifier";
// Backstop for runExclusive: no single job's own internal timeouts (e.g.
// certificate-service's per-cert issuance timeout) are relied on here. This
// is the last line of defense - if *anything* inside a job hangs with no
// error (a stalled Redis/DB call, a future code path that forgets to bound
// itself, etc.), state.active must still reset so the next tick can run.
// Without it, one hung run permanently skips every future tick for that job,
// since runExclusive only clears state.active after the job promise settles.
const RUN_EXCLUSIVE_TIMEOUT_MS = 30 * 60 * 1000;
export class JobScheduler {
private certIntervals: NodeJS.Timeout[] = [];
private dnsIntervals: NodeJS.Timeout[] = [];
private certRunning = false;
private dnsRunning = false;
// Guards against a slow batch (e.g. 10 certs whose DNS challenges take a
// while) still being processed when the next interval tick fires -
// without this, overlapping ticks would each pull their own batch of up
// to 10 pending/renewal certs and process them concurrently instead of
// waiting for the prior batch to finish.
private runExclusive(
job: () => Promise<void>,
state: { active: boolean },
label: string
): () => Promise<void> {
return async () => {
if (state.active) {
logger.debug(
`Skipping ${label} tick - previous run still in progress`
);
return;
}
state.active = true;
try {
await withTimeout(job(), RUN_EXCLUSIVE_TIMEOUT_MS, label);
} catch (error) {
logger.error(`Error in ${label}:`, error);
} finally {
state.active = false;
}
};
}
// Certificate issuance/renewal - requires an ACME client, so this is
// only started when Pangolin is actually managing certs.
async start(): Promise<void> {
if (this.certRunning) {
logger.warn("Certificate job scheduler is already running");
return;
}
this.certRunning = true;
logger.info("Starting certificate job scheduler");
const newCertState = { active: false };
const renewalState = { active: false };
const runNewCertCheck = this.runExclusive(
() => certificateService.processPendingCertificates(),
newCertState,
"processing pending certificates"
);
const runRenewalCheck = this.runExclusive(
() => certificateService.processRenewalCandidates(),
renewalState,
"processing renewal candidates"
);
// Schedule new certificate processing
const newCertInterval = setInterval(
runNewCertCheck,
config.getRawConfig().acme!.new_cert_check_interval_ms
);
// Schedule renewal processing (every 24 hours)
const renewalInterval = setInterval(
runRenewalCheck,
config.getRawConfig().acme!.renewal_check_interval_ms
);
this.certIntervals.push(newCertInterval, renewalInterval);
// Run initial checks
setTimeout(async () => {
try {
await runNewCertCheck();
// await runRenewalCheck();
} catch (error) {
logger.error("Error in initial certificate processing:", error);
}
}, 1000); // Wait 1 second after startup
logger.info("Certificate job scheduler started successfully");
}
// DNS record validation/reverification - doesn't touch certs at all, so
// this runs independently whenever Pangolin is acting as the
// authoritative DNS server, regardless of cert_mode.
async startDnsJobs(): Promise<void> {
if (this.dnsRunning) {
logger.warn("DNS validation job scheduler is already running");
return;
}
this.dnsRunning = true;
logger.info("Starting DNS validation job scheduler");
const dnsValidationState = { active: false };
const reverifyState = { active: false };
const runDnsValidation = this.runExclusive(
() => dnsValidator.validateAll(),
dnsValidationState,
"validating DNS records"
);
const runReverify = this.runExclusive(
() => domainReverifier.reverifyAll(),
reverifyState,
"reverifying domains"
);
// Schedule DNS validation
const dnsValidationInterval = setInterval(
runDnsValidation,
config.getRawConfig().acme?.dns_check_interval_ms ?? 60000
);
// Schedule periodic reverification of already-verified domains
const reverifyInterval = setInterval(
runReverify,
config.getRawConfig().acme?.domain_reverification_interval_ms ??
3600000
);
this.dnsIntervals.push(dnsValidationInterval, reverifyInterval);
// Run an initial validation pass shortly after startup
setTimeout(async () => {
try {
await runDnsValidation();
} catch (error) {
logger.error("Error in initial DNS validation:", error);
}
}, 1000);
logger.info("DNS validation job scheduler started successfully");
}
async stop(): Promise<void> {
if (this.certRunning) {
logger.info("Stopping certificate job scheduler");
this.certRunning = false;
this.certIntervals.forEach((interval) => clearInterval(interval));
this.certIntervals = [];
}
if (this.dnsRunning) {
logger.info("Stopping DNS validation job scheduler");
this.dnsRunning = false;
this.dnsIntervals.forEach((interval) => clearInterval(interval));
this.dnsIntervals = [];
}
}
isRunning(): boolean {
return this.certRunning || this.dnsRunning;
}
}
export const jobScheduler = new JobScheduler();
+8
View File
@@ -146,12 +146,20 @@ export class PrivateConfig {
process.env.USE_PANGOLIN_DNS =
this.rawPrivateConfig.flags.use_pangolin_dns.toString();
}
if (this.rawPrivateConfig.acme?.cert_mode) {
process.env.CERT_MODE = this.rawPrivateConfig.acme.cert_mode;
}
}
public getRawPrivateConfig() {
return this.rawPrivateConfig;
}
public getRawConfig() {
return this.getRawPrivateConfig();
}
// `flags.enable_acme_cert_sync`, `flags.disable_private_http_placeholder`,
// and `acme` used to live in the private config file. They now live in
// the public config file. If an operator still has them set in the
+14
View File
@@ -0,0 +1,14 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
export * from "./server";
File diff suppressed because it is too large Load Diff
+141 -6
View File
@@ -12,7 +12,7 @@
*/
import fs from "fs";
import yaml from "js-yaml";
import * as yaml from "js-yaml";
import { privateConfigFilePath1 } from "@server/lib/consts";
import { z } from "zod";
import { colorsSchema } from "@server/lib/colorsSchema";
@@ -95,6 +95,70 @@ export const privateConfigSchema = z
.optional()
})
.optional(),
dns: z
.object({
enabled: z.boolean().optional().default(false),
listen_port: z.number().int().positive().optional().default(53),
nameserver_name: z.string(),
cname_extension: z.string(),
site_extension: z.string().optional(),
cname_alternate_extensions: z
.array(z.string())
.optional()
.default([]),
alternate_nameservers: z
.array(z.string())
.optional()
.default([]),
rate_limit: z
.object({
enabled: z.boolean().optional().default(true),
window_ms: z
.number()
.int()
.min(1000)
.max(600000)
.optional()
.default(60000),
max_requests: z
.number()
.int()
.min(50)
.max(100000)
.optional()
.default(1200),
max_requests_per_query_type: z
.number()
.int()
.min(10)
.max(50000)
.optional()
.default(600)
})
.default({
enabled: true,
window_ms: 60000,
max_requests: 1200,
max_requests_per_query_type: 600
}),
static_records: z
.array(
z.object({
domain: z.string(),
type: z.enum(["TXT", "CNAME", "A", "NS"]),
value: z.string(),
ttl: z
.number()
.int()
.positive()
.optional()
.default(300)
})
)
.optional()
.default([])
})
.optional(),
gerbil: z
.object({
local_exit_node_reachable_at: z
@@ -125,15 +189,86 @@ export const privateConfigSchema = z
})
.optional()
.prefault({}),
// @deprecated Moved to the public config file as `acme`
// (server/lib/readConfigFile.ts). Kept here only so existing private
// config files keep parsing; any value set here is migrated into the
// public config at startup by PrivateConfig (server/private/lib/config.ts).
acme: z
.object({
cert_mode: z
.enum(["traefik", "pangolin"])
.optional()
.default("traefik"),
enable_acme_client: z.boolean().optional().default(false),
// @deprecated Moved to the public config file
// (server/lib/readConfigFile.ts). Kept here only so existing private
// config files keep parsing; any value set here is migrated into the
// public config at startup by PrivateConfig (server/private/lib/config.ts).
acme_json_path: z.string().optional(),
// @deprecated Moved to the public config file
// (server/lib/readConfigFile.ts). Kept here only so existing private
// config files keep parsing; any value set here is migrated into the
// public config at startup by PrivateConfig (server/private/lib/config.ts).
acme_http_endpoint: z.string().optional(),
sync_interval_ms: z.number().optional()
// @deprecated Moved to the public config file
// (server/lib/readConfigFile.ts). Kept here only so existing private
// config files keep parsing; any value set here is migrated into the
// public config at startup by PrivateConfig (server/private/lib/config.ts).
sync_interval_ms: z.number().optional(),
acme_directory_url: z
.string()
.url()
.default("https://acme-v02.api.letsencrypt.org/directory"),
contact_email: z.string().email(),
acme_account_key_path: z
.string()
.default("./config/account.key"),
challenge_ttl_ms: z.number().int().positive().default(300000),
renewal_check_interval_ms: z
.number()
.int()
.positive()
.default(3600000),
new_cert_check_interval_ms: z
.number()
.int()
.positive()
.default(60000),
// Kept safely under Let's Encrypt's ~20 req/s limit since this
// budget is shared across all pops workers and only covers the
// request-issuing calls we make directly (not every request
// acme-client makes internally, e.g. while polling for
// challenge/order status).
acme_requests_per_second: z
.number()
.int()
.positive()
.default(15),
dns_check_interval_ms: z
.number()
.int()
.positive()
.default(60000),
domain_reverification_interval_ms: z
.number()
.int()
.positive()
.default(3600000), // 1 hour — how often to run the reverification pass
domain_reverification_window_ms: z
.number()
.int()
.positive()
.default(259200000), // 72 hours — how old checkedAt must be before rechecking
domain_reverification_batch_size: z
.number()
.int()
.positive()
.default(20), // max domains to recheck per pass
dns_resolvers: z
.array(z.string())
.optional()
.default([
"8.8.8.8",
"1.1.1.1",
"9.9.9.9",
"208.67.222.222"
])
})
.optional(),
branding: z
+8 -6
View File
@@ -158,7 +158,7 @@ class RedisManager {
this.writeClient = new Redis({
...masterConfig,
enableReadyCheck: false,
maxRetriesPerRequest: 3,
maxRetriesPerRequest: 50,
keepAlive: 30000,
connectTimeout: this.connectionTimeout,
commandTimeout: this.commandTimeout
@@ -169,7 +169,7 @@ class RedisManager {
this.readClient = new Redis({
...replicaConfig!,
enableReadyCheck: false,
maxRetriesPerRequest: 3,
maxRetriesPerRequest: 50,
keepAlive: 30000,
connectTimeout: this.connectionTimeout,
commandTimeout: this.commandTimeout
@@ -186,7 +186,7 @@ class RedisManager {
this.publisher = new Redis({
...masterConfig,
enableReadyCheck: false,
maxRetriesPerRequest: 3,
maxRetriesPerRequest: 50,
keepAlive: 30000,
connectTimeout: this.connectionTimeout,
commandTimeout: this.commandTimeout
@@ -196,7 +196,7 @@ class RedisManager {
this.subscriber = new Redis({
...(this.hasReplicas ? replicaConfig! : masterConfig),
enableReadyCheck: false,
maxRetriesPerRequest: 3,
maxRetriesPerRequest: 50,
keepAlive: 30000,
connectTimeout: this.connectionTimeout,
commandTimeout: this.commandTimeout
@@ -901,7 +901,9 @@ class RegionalRedisManager {
// if the configured host doesn't match that pattern (e.g. local dev),
// in which case callers should fall back to the primary for reads.
private getReplicaHost(primaryHost: string): string | null {
const match = primaryHost.match(/^redis\.([^.]+)\.svc\.cluster\.local$/);
const match = primaryHost.match(
/^redis\.([^.]+)\.svc\.cluster\.local$/
);
if (!match) return null;
const namespace = match[1];
return `redis-1.redis-headless.${namespace}.svc.cluster.local`;
@@ -912,7 +914,7 @@ class RegionalRedisManager {
const baseOpts = {
...cfg,
enableReadyCheck: false,
maxRetriesPerRequest: 3,
maxRetriesPerRequest: 50,
keepAlive: 10000,
connectTimeout: this.connectionTimeout,
commandTimeout: this.commandTimeout
+18 -8
View File
@@ -396,7 +396,7 @@ export async function getTraefikConfig(
);
let validCerts: CertificateResult[] = [];
if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
if (privateConfig.getRawPrivateConfig().acme?.cert_mode == "pangolin") {
// create a list of all domains to get certs for
const domains = new Set<string>();
for (const resource of resourcesMap.values()) {
@@ -522,7 +522,10 @@ export async function getTraefikConfig(
);
let tls = {};
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
if (
privateConfig.getRawPrivateConfig().acme?.cert_mode !=
"pangolin"
) {
tls = buildWildcardTls({
fullDomain,
hasSubdomain: !!resource.subdomain,
@@ -789,7 +792,8 @@ export async function getTraefikConfig(
preferWildcardCert
}) => {
if (
!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns
privateConfig.getRawPrivateConfig().acme?.cert_mode !=
"pangolin"
) {
return buildWildcardTls({
fullDomain,
@@ -832,7 +836,8 @@ export async function getTraefikConfig(
redirectHttpsMiddlewareName,
resolveTls: (fullDomain) => {
if (
!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns
privateConfig.getRawPrivateConfig().acme?.cert_mode !=
"pangolin"
) {
// siteResource aliases don't have a per-domain cert
// resolver stored, so always fall back to the global
@@ -924,7 +929,10 @@ export async function getTraefikConfig(
const rule = buildHostRule(fullDomain, ir.wildcard);
let tls: any = {};
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
if (
privateConfig.getRawPrivateConfig().acme?.cert_mode !=
"pangolin"
) {
tls = buildWildcardTls({
fullDomain,
hasSubdomain: !!ir.subdomain,
@@ -1005,7 +1013,8 @@ export async function getTraefikConfig(
let tls: any = {};
if (
!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns
privateConfig.getRawPrivateConfig().acme?.cert_mode !=
"pangolin"
) {
// siteResource aliases don't have a per-domain cert
// resolver stored, so always fall back to the global
@@ -1080,7 +1089,7 @@ export async function getTraefikConfig(
.where(eq(exitNodes.exitNodeId, exitNodeId));
let validCertsLoginPages: CertificateResult[] = [];
if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
if (privateConfig.getRawPrivateConfig().acme?.cert_mode == "pangolin") {
// create a list of all domains to get certs for
const domains = new Set<string>();
for (const lp of exitNodeLoginPages) {
@@ -1126,7 +1135,8 @@ export async function getTraefikConfig(
const tls = {};
if (
!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns
privateConfig.getRawPrivateConfig().acme?.cert_mode !=
"pangolin"
) {
// TODO: we need to add the wildcard logic here too
} else {
@@ -14,7 +14,7 @@
import { getRandomItemInArray } from "@app/lib/getRandomItemInArray";
import response from "@server/lib/response";
import logger from "@server/logger";
import { processTestAlerts } from "@server/private/lib/alerts/processTestAlerts";
import { processTestAlerts } from "#private/lib/alerts/processTestAlerts";
import { type AlertAction } from "@server/routers/alertRule/types";
import HttpCode from "@server/types/HttpCode";
import { NextFunction, Request, Response } from "express";
@@ -33,8 +33,11 @@ import { OpenAPITags, registry } from "@server/openApi";
import { and, eq } from "drizzle-orm";
import { encrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
import { HC_EVENT_TYPES, SITE_EVENT_TYPES, RESOURCE_EVENT_TYPES } from "./createAlertRule";
import { invalidateAllRemoteExitNodeSessions } from "@server/private/auth/sessions/remoteExitNode";
import {
HC_EVENT_TYPES,
SITE_EVENT_TYPES,
RESOURCE_EVENT_TYPES
} from "./createAlertRule";
const paramsSchema = z
.object({
@@ -85,35 +88,57 @@ const bodySchema = z
const isHcEvent = (HC_EVENT_TYPES as readonly string[]).includes(
val.eventType
);
const isResourceEvent = (RESOURCE_EVENT_TYPES as readonly string[]).includes(
val.eventType
);
const isResourceEvent = (
RESOURCE_EVENT_TYPES as readonly string[]
).includes(val.eventType);
if (isSiteEvent && val.siteIds !== undefined && val.siteIds.length === 0 && !val.allSites) {
if (
isSiteEvent &&
val.siteIds !== undefined &&
val.siteIds.length === 0 &&
!val.allSites
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "At least one siteId is required for site event types when allSites is false",
message:
"At least one siteId is required for site event types when allSites is false",
path: ["siteIds"]
});
}
if (isHcEvent && val.healthCheckIds !== undefined && val.healthCheckIds.length === 0 && !val.allHealthChecks) {
if (
isHcEvent &&
val.healthCheckIds !== undefined &&
val.healthCheckIds.length === 0 &&
!val.allHealthChecks
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "At least one healthCheckId is required for health check event types when allHealthChecks is false",
message:
"At least one healthCheckId is required for health check event types when allHealthChecks is false",
path: ["healthCheckIds"]
});
}
if (isResourceEvent && val.resourceIds !== undefined && val.resourceIds.length === 0 && !val.allResources) {
if (
isResourceEvent &&
val.resourceIds !== undefined &&
val.resourceIds.length === 0 &&
!val.allResources
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "At least one resourceId is required for resource event types when allResources is false",
message:
"At least one resourceId is required for resource event types when allResources is false",
path: ["resourceIds"]
});
}
if (isSiteEvent && val.healthCheckIds !== undefined && val.healthCheckIds.length > 0) {
if (
isSiteEvent &&
val.healthCheckIds !== undefined &&
val.healthCheckIds.length > 0
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "healthCheckIds must not be set for site event types",
@@ -129,7 +154,11 @@ const bodySchema = z
});
}
if (isResourceEvent && val.siteIds !== undefined && val.siteIds.length > 0) {
if (
isResourceEvent &&
val.siteIds !== undefined &&
val.siteIds.length > 0
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "siteIds must not be set for resource event types",
@@ -137,10 +166,15 @@ const bodySchema = z
});
}
if (isResourceEvent && val.healthCheckIds !== undefined && val.healthCheckIds.length > 0) {
if (
isResourceEvent &&
val.healthCheckIds !== undefined &&
val.healthCheckIds.length > 0
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "healthCheckIds must not be set for resource event types",
message:
"healthCheckIds must not be set for resource event types",
path: ["healthCheckIds"]
});
}
@@ -153,7 +187,6 @@ const UpdateAlertRuleResponseDataSchema = z.object({
alertRuleId: z.number()
});
registry.registerPath({
method: "post",
path: "/org/{orgId}/alert-rule/{alertRuleId}",
@@ -174,7 +207,9 @@ registry.registerPath({
description: "Successful response",
content: {
"application/json": {
schema: createApiResponseSchema(UpdateAlertRuleResponseDataSchema)
schema: createApiResponseSchema(
UpdateAlertRuleResponseDataSchema
)
}
}
}
@@ -250,9 +285,11 @@ export async function updateAlertRule(
if (name !== undefined) updateData.name = name;
if (eventType !== undefined) updateData.eventType = eventType;
if (enabled !== undefined) updateData.enabled = enabled;
if (cooldownSeconds !== undefined) updateData.cooldownSeconds = cooldownSeconds;
if (cooldownSeconds !== undefined)
updateData.cooldownSeconds = cooldownSeconds;
if (allSites !== undefined) updateData.allSites = allSites;
if (allHealthChecks !== undefined) updateData.allHealthChecks = allHealthChecks;
if (allHealthChecks !== undefined)
updateData.allHealthChecks = allHealthChecks;
if (allResources !== undefined) updateData.allResources = allResources;
await db
@@ -273,7 +310,11 @@ export async function updateAlertRule(
// Only insert junction rows when allSites is not true
const effectiveAllSites = allSites ?? false;
if (!effectiveAllSites && siteIds !== undefined && siteIds.length > 0) {
if (
!effectiveAllSites &&
siteIds !== undefined &&
siteIds.length > 0
) {
await db.insert(alertSites).values(
siteIds.map((siteId) => ({
alertRuleId,
@@ -290,7 +331,11 @@ export async function updateAlertRule(
.where(eq(alertHealthChecks.alertRuleId, alertRuleId));
const effectiveAllHealthChecks = allHealthChecks ?? false;
if (!effectiveAllHealthChecks && healthCheckIds !== undefined && healthCheckIds.length > 0) {
if (
!effectiveAllHealthChecks &&
healthCheckIds !== undefined &&
healthCheckIds.length > 0
) {
await db.insert(alertHealthChecks).values(
healthCheckIds.map((healthCheckId) => ({
alertRuleId,
@@ -307,7 +352,11 @@ export async function updateAlertRule(
.where(eq(alertResources.alertRuleId, alertRuleId));
const effectiveAllResources = allResources ?? false;
if (!effectiveAllResources && resourceIds !== undefined && resourceIds.length > 0) {
if (
!effectiveAllResources &&
resourceIds !== undefined &&
resourceIds.length > 0
) {
await db.insert(alertResources).values(
resourceIds.map((resourceId) => ({
alertRuleId,
@@ -392,7 +441,10 @@ export async function updateAlertRule(
webhookActions.map((wa) => ({
alertRuleId,
webhookUrl: wa.webhookUrl,
config: wa.config != null ? encrypt(wa.config, serverSecret) : null,
config:
wa.config != null
? encrypt(wa.config, serverSecret)
: null,
enabled: wa.enabled
}))
);
@@ -31,7 +31,9 @@ export async function clearInstanceName(
next: NextFunction
): Promise<any> {
try {
const parsedParams = clearInstanceNameParamsSchema.safeParse(req.params);
const parsedParams = clearInstanceNameParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
@@ -63,7 +65,8 @@ export async function clearInstanceName(
return next(
createHttpError(
data.status || HttpCode.BAD_REQUEST,
data.message || "Failed to clear instance name from Fossorial API"
data.message ||
"Failed to clear server ID from Fossorial API"
)
);
}
@@ -72,7 +75,7 @@ export async function clearInstanceName(
data: null,
success: true,
error: false,
message: "Instance name cleared successfully",
message: "Server ID cleared successfully",
status: HttpCode.OK
});
} catch (error) {
@@ -80,8 +83,8 @@ export async function clearInstanceName(
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"An error occurred while clearing the instance name."
"An error occurred while clearing the server ID."
)
);
}
}
}
+6 -1
View File
@@ -2478,7 +2478,12 @@ hybridRouter.post(
destinations: destinations
});
} catch (error) {
logger.error(error);
if (!(
error instanceof Error &&
error.message === "Exit node not allowed"
)) {
logger.error(error);
}
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
@@ -0,0 +1,23 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { EventEmitter } from "events";
export interface ExitNodeOnlineEvent {
exitNodeId: number;
endpoint: string;
}
export const EXIT_NODE_ONLINE_EVENT = "exit-node-online";
export const exitNodeEvents = new EventEmitter();
@@ -12,11 +12,27 @@
*/
import axios from "axios";
import { db, exitNodes, newts, sites } from "@server/db";
import { db, newts, sites } from "@server/db";
import { eq } from "drizzle-orm";
import logger from "@server/logger";
import redisManager from "#private/lib/redis";
// import { sendToClient } from "#private/routers/ws";
import { sendToClient } from "../ws";
import {
exitNodeEvents,
EXIT_NODE_ONLINE_EVENT,
ExitNodeOnlineEvent
} from "./exitNodeEvents";
exitNodeEvents.on(
EXIT_NODE_ONLINE_EVENT,
({ exitNodeId, endpoint }: ExitNodeOnlineEvent) => {
scheduleExitNodeReconnect(exitNodeId, endpoint).catch((error) => {
logger.error("Failed to schedule exit node reconnect", {
error
});
});
}
);
const INITIAL_DELAY_MS = 15 * 1000; // 15 seconds before first check
const CHECK_INTERVAL_MS = 10 * 1000; // Check every 10 seconds
@@ -26,7 +42,7 @@ const REDIS_HASH_PREFIX = "exit-node-reconnect:";
interface PendingReconnect {
startTime: number;
reachableAt: string;
endpoint: string;
}
// In-memory tracking for this node
@@ -40,15 +56,15 @@ let schedulerInterval: NodeJS.Timeout | null = null;
*/
export async function scheduleExitNodeReconnect(
exitNodeId: number,
reachableAt: string
endpoint: string
): Promise<void> {
logger.info(
`Scheduling newt reconnect for exit node ${exitNodeId} (reachableAt: ${reachableAt})`
`Scheduling newt reconnect for exit node ${exitNodeId} (endpoint: ${endpoint})`
);
const entry: PendingReconnect = {
startTime: Date.now(),
reachableAt
endpoint
};
pendingReconnects.set(exitNodeId, entry);
@@ -63,8 +79,8 @@ export async function scheduleExitNodeReconnect(
);
await redisManager.hset(
`${REDIS_HASH_PREFIX}${exitNodeId}`,
"reachableAt",
reachableAt
"endpoint",
endpoint
);
}
}
@@ -101,14 +117,14 @@ async function processPendingReconnects(): Promise<void> {
`${REDIS_HASH_PREFIX}${id}`,
"startTime"
);
const reachableAt = await redisManager.hget(
const endpoint = await redisManager.hget(
`${REDIS_HASH_PREFIX}${id}`,
"reachableAt"
"endpoint"
);
if (startTimeStr && reachableAt) {
if (startTimeStr && endpoint) {
toProcess.set(id, {
startTime: parseInt(startTimeStr, 10),
reachableAt
endpoint
});
}
}
@@ -135,7 +151,7 @@ async function processPendingReconnects(): Promise<void> {
}
// Check if the exit node HTTP endpoint is reachable
const pingUrl = `${entry.reachableAt}/ping`;
const pingUrl = `http://${entry.endpoint}/ping`;
try {
await axios.get(pingUrl, { timeout: 5000 });
} catch {
@@ -150,47 +166,47 @@ async function processPendingReconnects(): Promise<void> {
`Exit node ${exitNodeId} is reachable. Sending newt/wg/reconnect to connected newts.`
);
// await sendReconnectToNewts(exitNodeId);
await sendReconnectToNewts(exitNodeId);
await removePending(exitNodeId);
}
}
// async function sendReconnectToNewts(exitNodeId: number): Promise<void> {
// try {
// const connectedNewts = await db
// .select({ newtId: newts.newtId })
// .from(newts)
// .innerJoin(sites, eq(newts.siteId, sites.siteId))
// .where(eq(sites.exitNodeId, exitNodeId));
async function sendReconnectToNewts(exitNodeId: number): Promise<void> {
try {
const connectedNewts = await db
.select({ newtId: newts.newtId })
.from(newts)
.innerJoin(sites, eq(newts.siteId, sites.siteId))
.where(eq(sites.exitNodeId, exitNodeId));
// if (connectedNewts.length === 0) {
// logger.debug(
// `No newts found for exit node ${exitNodeId}, nothing to reconnect`
// );
// return;
// }
if (connectedNewts.length === 0) {
logger.debug(
`No newts found for exit node ${exitNodeId}, nothing to reconnect`
);
return;
}
// logger.info(
// `Sending newt/wg/reconnect to ${connectedNewts.length} newt(s) for exit node ${exitNodeId}`
// );
logger.info(
`Sending newt/wg/reconnect to ${connectedNewts.length} newt(s) for exit node ${exitNodeId}`
);
// const reconnectMessage = {
// type: "newt/wg/reconnect",
// data: {}
// };
const reconnectMessage = {
type: "newt/wg/reconnect",
data: {}
};
// await Promise.allSettled(
// connectedNewts.map(({ newtId }) =>
// sendToClient(newtId, reconnectMessage)
// )
// );
// } catch (error) {
// logger.error(
// `Failed to send reconnect messages for exit node ${exitNodeId}`,
// { error }
// );
// }
// }
await Promise.allSettled(
connectedNewts.map(({ newtId }) =>
sendToClient(newtId, reconnectMessage)
)
);
} catch (error) {
logger.error(
`Failed to send reconnect messages for exit node ${exitNodeId}`,
{ error }
);
}
}
async function removePending(exitNodeId: number): Promise<void> {
pendingReconnects.delete(exitNodeId);
@@ -26,7 +26,7 @@ import {
validateRemoteExitNodeSessionToken,
EXPIRES
} from "#private/auth/sessions/remoteExitNode";
import { getOrCreateCachedToken } from "@server/private/lib/tokenCache";
import { getOrCreateCachedToken } from "#private/lib/tokenCache";
import { verifyPassword } from "@server/auth/password";
import logger from "@server/logger";
import config from "@server/lib/config";
@@ -16,7 +16,7 @@ import { MessageHandler } from "@server/routers/ws";
import { RemoteExitNode } from "@server/db";
import { eq } from "drizzle-orm";
import logger from "@server/logger";
import { scheduleExitNodeReconnect } from "./exitNodeReconnectScheduler";
import { exitNodeEvents, EXIT_NODE_ONLINE_EVENT } from "./exitNodeEvents";
/**
* Handles ping messages from clients and responds with pong
@@ -40,7 +40,7 @@ export const handleRemoteExitNodePingMessage: MessageHandler = async (
try {
// Fetch the current state before updating so we can detect the offline→online transition
const [currentExitNode] = await db
.select({ online: exitNodes.online, reachableAt: exitNodes.reachableAt })
.select({ online: exitNodes.online, endpoint: exitNodes.endpoint })
.from(exitNodes)
.where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId))
.limit(1);
@@ -55,12 +55,14 @@ export const handleRemoteExitNodePingMessage: MessageHandler = async (
.where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId));
// If the exit node was offline and is now coming online, schedule newt reconnects
if (currentExitNode && !currentExitNode.online && currentExitNode.reachableAt) {
scheduleExitNodeReconnect(
remoteExitNode.exitNodeId,
currentExitNode.reachableAt
).catch((error) => {
logger.error("Failed to schedule exit node reconnect", { error });
if (
currentExitNode &&
!currentExitNode.online &&
currentExitNode.endpoint
) {
exitNodeEvents.emit(EXIT_NODE_ONLINE_EVENT, {
exitNodeId: remoteExitNode.exitNodeId,
endpoint: currentExitNode.endpoint
});
}
} catch (error) {
+2 -1
View File
@@ -104,7 +104,8 @@ const processMessage = async (
const handler = messageHandlers[message.type];
if (!handler) {
throw new Error(`Unsupported message type: ${message.type}`);
logger.debug(`No handler found for message type: ${message.type}`);
return;
}
const response = await handler({
+7 -4
View File
@@ -300,13 +300,16 @@ export async function createOrgDomain(
{
value: `${domainId}.${config.getRawConfig().dns.cname_extension}`,
baseDomain: baseDomain
},
{
value: `_acme-challenge.${domainId}.${config.getRawConfig().dns.cname_extension}`,
baseDomain: `_acme-challenge.${baseDomain}`
}
];
if (build == "saas") {
cnameRecords.push({
value: `_acme-challenge.${domainId}.${config.getRawConfig().dns.cname_extension}`,
baseDomain: `_acme-challenge.${baseDomain}`
});
}
// Save CNAME records to database
for (const cnameRecord of cnameRecords) {
recordsToInsert.push({
-6
View File
@@ -1378,12 +1378,6 @@ if (build !== "saas") {
user.adminGeneratePasswordResetCode
);
authenticated.post(
"/user/:userId/server-admin",
verifyUserIsServerAdmin,
user.adminSetServerAdmin
);
authenticated.delete(
"/user/:userId",
verifyUserIsServerAdmin,
@@ -984,7 +984,7 @@ async function listLabelGroups(
).length;
}
let groups: LauncherGroup[] = Array.from(labelCountMap.values()).map(
const groups: LauncherGroup[] = Array.from(labelCountMap.values()).map(
(row) => ({
groupKey: String(row.labelId),
name: row.name,
@@ -1,16 +1,3 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { db } from "@server/db";
import { MessageHandler } from "@server/routers/ws";
import { sites, Newt, orgs, clients, clientSitesAssociationsCache, users } from "@server/db";
+1 -1
View File
@@ -80,7 +80,7 @@ export async function buildSiteConfigurationForOlmClient(
);
const siteResourcesBySiteId = new Map<number, SiteResource[]>();
let siteResourcesForExitNode = [];
const siteResourcesForExitNode = [];
for (const row of allClientSiteResources) {
if (row.siteResource.requiresExitNodeConnection) {
siteResourcesForExitNode.push(row.siteResource);
+20
View File
@@ -0,0 +1,20 @@
import config from "@server/lib/config";
// Mirrors the optional fields on the olm client's TunnelConfig - any field
// present here overrides the value the olm client is otherwise locally
// configured with; an absent field leaves the client's own config alone.
export type OlmDnsConfig = {
upstreamDns?: string[];
overrideDns?: boolean;
tunnelDns?: boolean;
matchDomains?: string[];
};
export function buildOlmDnsConfig(): OlmDnsConfig | undefined {
return {
upstreamDns: undefined,
overrideDns: undefined,
tunnelDns: undefined,
matchDomains: undefined
};
}
@@ -15,6 +15,7 @@ import { encodeHexLowerCase } from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2";
import { getUserDeviceName } from "@server/db/names";
import { buildSiteConfigurationForOlmClient } from "./buildConfiguration";
import { buildOlmDnsConfig } from "./dnsConfig";
import { OlmErrorCodes, sendOlmError } from "./error";
import { handleFingerprintInsertion } from "./fingerprintingUtils";
import { build } from "@server/build";
@@ -512,6 +513,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
tunnelIP: `${clientSubnet.split("/")[0]}/${exitNode.address.split("/")[1]}` // we need to use the exit node's subnet mask here because the client will be using the exit node's subnet mask for its routing table so we can address it
}
: undefined,
dnsConfig: buildOlmDnsConfig(),
chainId: chainId
}
},
@@ -85,7 +85,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
);
if (!resources || resources.length === 0) {
logger.error(
logger.warn(
`handleOlmServerInitAddPeerHandshake: Resource not found`
);
await sendCancel();
@@ -94,7 +94,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
if (resources.length > 1) {
// error but this should not happen because the nice id cant contain a dot and the alias has to have a dot and both have to be unique within the org so there should never be multiple matches
logger.error(
logger.warn(
`handleOlmServerInitAddPeerHandshake: Multiple resources found matching the criteria`
);
return;
@@ -119,7 +119,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
);
if (currentResourceAssociationCaches.length === 0) {
logger.error(
logger.warn(
`handleOlmServerInitAddPeerHandshake: Client ${client.clientId} does not have access to resource ${resource.siteResourceId}`
);
await sendCancel();
@@ -127,7 +127,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
}
if (!resource.networkId) {
logger.error(
logger.warn(
`handleOlmServerInitAddPeerHandshake: Resource ${resource.siteResourceId} has no network`
);
await sendCancel();
@@ -141,7 +141,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
.where(eq(siteNetworks.networkId, resource.networkId));
if (!siteRows || siteRows.length === 0) {
logger.error(
logger.warn(
`handleOlmServerInitAddPeerHandshake: No sites found for resource ${resource.siteResourceId}`
);
await sendCancel();
@@ -164,9 +164,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
}
if (sitesToProcess.length === 0) {
logger.error(
`handleOlmServerInitAddPeerHandshake: No sites to process`
);
logger.warn(`handleOlmServerInitAddPeerHandshake: No sites to process`);
await sendCancel();
return;
}
@@ -193,7 +191,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
}
if (!site.exitNodeId) {
logger.error(
logger.warn(
`handleOlmServerInitAddPeerHandshake: Site ${site.siteId} has no exit node, skipping`
);
continue;
@@ -205,7 +203,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
.where(eq(exitNodes.exitNodeId, site.exitNodeId));
if (!exitNode) {
logger.error(
logger.warn(
`handleOlmServerInitAddPeerHandshake: Exit node not found for site ${site.siteId}, skipping`
);
continue;
@@ -229,7 +227,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
}
if (!handshakeInitiated) {
logger.error(
logger.warn(
`handleOlmServerInitAddPeerHandshake: No accessible sites with valid exit nodes found, cancelling chain`
);
await sendCancel();
+2 -2
View File
@@ -4,7 +4,7 @@ import { db, idp, users } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { and, asc, desc, eq, like, or, sql, type SQL } from "drizzle-orm";
import { and, asc, desc, eq, like, or, sql } from "drizzle-orm";
import logger from "@server/logger";
import { fromZodError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
@@ -196,7 +196,7 @@ export async function adminListUsers(
}
}
const conditions: Array<SQL<unknown> | undefined> = [];
const conditions = [eq(users.serverAdmin, false)];
if (query) {
const q = "%" + query.toLowerCase() + "%";
-142
View File
@@ -1,142 +0,0 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, users } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema";
const setServerAdminParamsSchema = z.strictObject({
userId: z.string()
});
const setServerAdminBodySchema = z.strictObject({
serverAdmin: z.boolean()
});
export type AdminSetServerAdminResponse = {
userId: string;
serverAdmin: boolean;
};
const AdminSetServerAdminResponseDataSchema = z.object({
userId: z.string(),
serverAdmin: z.boolean()
});
registry.registerPath({
method: "post",
path: "/user/{userId}/server-admin",
description: "Promote or demote a user's server admin status (server admin).",
tags: [OpenAPITags.User],
request: {
params: setServerAdminParamsSchema,
body: {
content: {
"application/json": {
schema: setServerAdminBodySchema
}
}
}
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: createApiResponseSchema(
AdminSetServerAdminResponseDataSchema
)
}
}
}
}
});
export async function adminSetServerAdmin(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = setServerAdminParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = setServerAdminBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { userId } = parsedParams.data;
const { serverAdmin } = parsedBody.data;
const [existingUser] = await db
.select({
userId: users.userId,
serverAdmin: users.serverAdmin
})
.from(users)
.where(eq(users.userId, userId))
.limit(1);
if (!existingUser) {
return next(createHttpError(HttpCode.NOT_FOUND, "User not found"));
}
if (!serverAdmin && req.user?.userId === userId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"You cannot remove your own server admin status"
)
);
}
if (existingUser.serverAdmin !== serverAdmin) {
logger.info(
`${serverAdmin ? "Promoting" : "Demoting"} user ${userId} ${serverAdmin ? "to" : "from"} server admin (by ${req.user?.userId})`
);
await db
.update(users)
.set({ serverAdmin })
.where(eq(users.userId, userId));
}
return response<AdminSetServerAdminResponse>(res, {
data: {
userId: existingUser.userId,
serverAdmin
},
success: true,
error: false,
message: serverAdmin
? "User promoted to server admin successfully"
: "User demoted from server admin successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
-1
View File
@@ -11,7 +11,6 @@ export * from "./adminListUsers";
export * from "./adminRemoveUser";
export * from "./adminGetUser";
export * from "./adminGeneratePasswordResetCode";
export * from "./adminSetServerAdmin";
export * from "./listInvitations";
export * from "./removeInvitation";
export * from "./createOrgUser";
+4 -1
View File
@@ -353,7 +353,10 @@ const setupConnection = async (
const handler = messageHandlers[message.type];
if (!handler) {
throw new Error(`Unsupported message type: ${message.type}`);
logger.debug(
`No handler found for message type: ${message.type}`
);
return;
}
const response = await handler({
+1 -1
View File
@@ -4,7 +4,7 @@ import { configFilePath1, configFilePath2 } from "@server/lib/consts";
import { encrypt } from "@server/lib/crypto";
import { generateCA } from "@server/lib/sshCA";
import fs from "fs";
import yaml from "js-yaml";
import * as yaml from "js-yaml";
const version = "1.16.0";
+1 -1
View File
@@ -2,7 +2,7 @@ import { db } from "@server/db/pg/driver";
import { APP_PATH, __DIRNAME } from "@server/lib/consts";
import { sql } from "drizzle-orm";
import fs from "fs";
import yaml from "js-yaml";
import * as yaml from "js-yaml";
import path, { join } from "path";
import z from "zod";
import { fromZodError } from "zod-validation-error";
+1 -1
View File
@@ -2,7 +2,7 @@ import { db } from "@server/db/pg/driver";
import { APP_PATH } from "@server/lib/consts";
import { sql } from "drizzle-orm";
import fs from "fs";
import yaml from "js-yaml";
import * as yaml from "js-yaml";
import path from "path";
import z from "zod";
import { fromZodError } from "zod-validation-error";
+1 -1
View File
@@ -3,7 +3,7 @@ import { db } from "@server/db/pg/driver";
import { APP_PATH } from "@server/lib/consts";
import { sql } from "drizzle-orm";
import fs from "fs";
import yaml from "js-yaml";
import * as yaml from "js-yaml";
import path from "path";
import z from "zod";
import { fromZodError } from "zod-validation-error";
+1 -1
View File
@@ -2,7 +2,7 @@ import { db } from "@server/db/pg/driver";
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
import { sql } from "drizzle-orm";
import fs from "fs";
import yaml from "js-yaml";
import * as yaml from "js-yaml";
const version = "1.6.0";
+1 -1
View File
@@ -1,6 +1,6 @@
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
import fs from "fs";
import yaml from "js-yaml";
import * as yaml from "js-yaml";
export default async function migration() {
console.log("Running setup script 1.0.0-beta.10...");
+1 -1
View File
@@ -2,7 +2,7 @@ import { db } from "../../db/sqlite";
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
import { sql } from "drizzle-orm";
import fs from "fs";
import yaml from "js-yaml";
import * as yaml from "js-yaml";
export default async function migration() {
console.log("Running setup script 1.0.0-beta.12...");
+1 -1
View File
@@ -1,7 +1,7 @@
import { db } from "../../db/sqlite";
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
import fs from "fs";
import yaml from "js-yaml";
import * as yaml from "js-yaml";
import { sql } from "drizzle-orm";
import { domains, orgDomains, resources } from "@server/db";
+1 -1
View File
@@ -1,6 +1,6 @@
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
import fs from "fs";
import yaml from "js-yaml";
import * as yaml from "js-yaml";
export default async function migration() {
console.log("Running setup script 1.0.0-beta.2...");
+1 -1
View File
@@ -1,6 +1,6 @@
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
import fs from "fs";
import yaml from "js-yaml";
import * as yaml from "js-yaml";
export default async function migration() {
console.log("Running setup script 1.0.0-beta.3...");
+1 -1
View File
@@ -1,6 +1,6 @@
import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts";
import fs from "fs";
import yaml from "js-yaml";
import * as yaml from "js-yaml";
import path from "path";
import { z } from "zod";
import { fromZodError } from "zod-validation-error";
+1 -1
View File
@@ -1,6 +1,6 @@
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
import fs from "fs";
import yaml from "js-yaml";
import * as yaml from "js-yaml";
export default async function migration() {
console.log("Running setup script 1.0.0-beta.6...");
+1 -1
View File
@@ -12,7 +12,7 @@ import {
import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts";
import { eq, sql } from "drizzle-orm";
import fs from "fs";
import yaml from "js-yaml";
import * as yaml from "js-yaml";
import path from "path";
import { z } from "zod";
import { fromZodError } from "zod-validation-error";
+1 -1
View File
@@ -1,6 +1,6 @@
import { APP_PATH } from "@server/lib/consts";
import fs from "fs";
import yaml from "js-yaml";
import * as yaml from "js-yaml";
import path from "path";
import { z } from "zod";
import { fromZodError } from "zod-validation-error";
+1 -1
View File
@@ -4,7 +4,7 @@ import { generateCA } from "@server/lib/sshCA";
import Database from "better-sqlite3";
import fs from "fs";
import path from "path";
import yaml from "js-yaml";
import * as yaml from "js-yaml";
const version = "1.16.0";

Some files were not shown because too many files have changed in this diff Show More