Compare commits

..

780 Commits

Author SHA1 Message Date
dependabot[bot] 62278d110f Bump the npm-dependencies group across 1 directory with 70 updates
Bumps the npm-dependencies group with 70 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.1111.0` |
| [@hookform/resolvers](https://github.com/react-hook-form/resolvers) | `5.4.0` | `5.9.0` |
| [@node-rs/argon2](https://github.com/napi-rs/node-rs) | `2.0.2` | `2.1.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.2` |
| [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) | `5.100.14` | `5.101.4` |
| [@tanstack/react-table](https://github.com/TanStack/table/tree/HEAD/packages/react-table) | `8.21.3` | `9.1.2` |
| [axios](https://github.com/axios/axios) | `1.18.0` | `1.19.0` |
| [express-rate-limit](https://github.com/express-rate-limit/express-rate-limit) | `8.5.2` | `8.6.2` |
| [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` |
| [ioredis](https://github.com/redis/ioredis) | `5.11.0` | `6.0.0` |
| [js-yaml](https://github.com/nodeca/js-yaml) | `4.3.1` | `5.3.0` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.17.0` | `1.31.0` |
| [maxmind](https://github.com/runk/node-maxmind) | `5.0.6` | `5.0.7` |
| [next-intl](https://github.com/amannn/next-intl) | `4.13.0` | `4.13.6` |
| [nodemailer](https://github.com/nodemailer/nodemailer) | `9.0.1` | `9.0.5` |
| [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.21.0` |
| [posthog-node](https://github.com/PostHog/posthog-js/tree/HEAD/packages/node) | `5.35.6` | `5.49.1` |
| [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.4` |
| [react-hook-form](https://github.com/react-hook-form/react-hook-form) | `7.76.1` | `7.85.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.5.0` |
| [uuid](https://github.com/uuidjs/uuid) | `14.0.0` | `14.0.1` |
| [ws](https://github.com/websockets/ws) | `8.21.0` | `8.21.3` |
| [yargs](https://github.com/yargs/yargs) | `18.0.0` | `18.1.0` |
| [@dotenvx/dotenvx](https://github.com/dotenvx/dotenvx) | `1.69.1` | `2.21.0` |
| [@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.101.4` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.9.1` | `26.2.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.8.1` |
| [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) | `16.2.6` | `16.3.1` |
| [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.2` |
| [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.1` |
| [tsx](https://github.com/privatenumber/tsx) | `4.22.3` | `4.23.12` |
| [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.67.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.1111.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.1111.0/clients/client-s3)

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

Updates `@node-rs/argon2` from 2.0.2 to 2.1.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.1.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.2
- [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.2/packages/server)

Updates `@tanstack/react-query` from 5.100.14 to 5.101.4
- [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.101.4/packages/react-query)

Updates `@tanstack/react-table` from 8.21.3 to 9.1.2
- [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.1.2/packages/react-table)

Updates `axios` from 1.18.0 to 1.19.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.19.0)

Updates `express-rate-limit` from 8.5.2 to 8.6.2
- [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.6.2)

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 `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.3.0
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.3.1...5.3.0)

Updates `lucide-react` from 1.17.0 to 1.31.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.31.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-intl` from 4.13.0 to 4.13.6
- [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.13.6)

Updates `nodemailer` from 9.0.1 to 9.0.5
- [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.0.5)

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.21.0
- [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.49.1
- [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.49.1/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.4
- [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.85.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.85.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.5.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.5.0)

Updates `uuid` from 14.0.0 to 14.0.1
- [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.1)

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 `@dotenvx/dotenvx` from 1.69.1 to 2.21.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.21.0)

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.101.4
- [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.101.4/packages/react-query-devtools)

Updates `@types/node` from 25.9.1 to 26.2.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.21.0
- [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.4
- [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.8.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.4.0...v10.8.1)

Updates `eslint-config-next` from 16.2.6 to 16.3.1
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.3.1/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.2
- [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.2/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.1
- [Release notes](https://github.com/justkey007/tsc-alias/releases)
- [Commits](https://github.com/justkey007/tsc-alias/compare/v1.8.17...v1.9.1)

Updates `tsx` from 4.22.3 to 4.23.12
- [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.12)

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

Updates `typescript-eslint` from 8.60.0 to 8.67.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.67.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.1111.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@hookform/resolvers"
  dependency-version: 5.9.0
  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: "@simplewebauthn/server"
  dependency-version: 13.3.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.101.4
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@tanstack/react-table"
  dependency-version: 9.1.2
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: npm-dependencies
- dependency-name: axios
  dependency-version: 1.19.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: express-rate-limit
  dependency-version: 8.6.2
  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: 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.3.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: npm-dependencies
- dependency-name: lucide-react
  dependency-version: 1.31.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-intl
  dependency-version: 4.13.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: nodemailer
  dependency-version: 9.0.5
  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: "@types/pg"
  dependency-version: 8.21.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: posthog-node
  dependency-version: 5.49.1
  dependency-type: direct:production
  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: "@types/react"
  dependency-version: 19.2.18
  dependency-type: direct:development
  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: "@types/react-dom"
  dependency-version: 19.2.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: react-hook-form
  dependency-version: 7.85.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: "@types/semver"
  dependency-version: 7.8.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: stripe
  dependency-version: 22.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: uuid
  dependency-version: 14.0.1
  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: "@dotenvx/dotenvx"
  dependency-version: 2.21.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  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-devtools"
  dependency-version: 5.101.4
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@types/node"
  dependency-version: 26.2.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.21.0
  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-dom"
  dependency-version: 19.2.4
  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/sshpk"
  dependency-version: 1.17.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  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.8.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: eslint-config-next
  dependency-version: 16.3.1
  dependency-type: direct:development
  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: prettier
  dependency-version: 3.9.6
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: react-email
  dependency-version: 6.9.2
  dependency-type: direct:development
  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.1
  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.67.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-19 15:38:46 +00:00
Milo Schwartz 71348f45b2 Merge pull request #3611 from fosrl/dev
1.22.0
2026-08-19 11:26:08 -04:00
Owen Schwartz 664b7d52af Merge pull request #3610 from fosrl/crowdin_dev
New Crowdin updates
2026-08-19 11:14:26 -04:00
Owen Schwartz fb6f9c91c6 New translations en-us.json (French)
[ci skip]
2026-08-19 11:12:39 -04:00
Owen Schwartz 051c873fdf New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-08-19 11:12:37 -04:00
Owen Schwartz 12224458e7 New translations en-us.json (Chinese Simplified)
[ci skip]
2026-08-19 11:12:36 -04:00
Owen Schwartz b6719d2a3e New translations en-us.json (Turkish)
[ci skip]
2026-08-19 11:12:34 -04:00
Owen Schwartz 7843b56f39 New translations en-us.json (Russian)
[ci skip]
2026-08-19 11:12:32 -04:00
Owen Schwartz 19be6c4b3b New translations en-us.json (Portuguese)
[ci skip]
2026-08-19 11:12:30 -04:00
Owen Schwartz 4a283710ad New translations en-us.json (Polish)
[ci skip]
2026-08-19 11:12:28 -04:00
Owen Schwartz 1af7c5ee73 New translations en-us.json (Dutch)
[ci skip]
2026-08-19 11:12:27 -04:00
Owen Schwartz 77dd47fa8a New translations en-us.json (Korean)
[ci skip]
2026-08-19 11:12:25 -04:00
Owen Schwartz ccfe91f08d New translations en-us.json (Italian)
[ci skip]
2026-08-19 11:12:23 -04:00
Owen Schwartz 624fb8322f New translations en-us.json (German)
[ci skip]
2026-08-19 11:12:21 -04:00
Owen Schwartz 6bc3242a29 New translations en-us.json (Danish)
[ci skip]
2026-08-19 11:12:20 -04:00
Owen Schwartz 33f528ef79 New translations en-us.json (Czech)
[ci skip]
2026-08-19 11:12:18 -04:00
Owen Schwartz 6c1afc4453 New translations en-us.json (Bulgarian)
[ci skip]
2026-08-19 11:12:16 -04:00
Owen Schwartz 2a14447f8c New translations en-us.json (Spanish)
[ci skip]
2026-08-19 11:12:14 -04:00
miloschwartz ad50f7473a adjust translation 2026-08-19 10:53:30 -04:00
miloschwartz 5f1218f7b1 show placeholder in logs for nullable fields 2026-08-19 10:51:21 -04:00
miloschwartz e377afe9f6 add id sorting to http request logs 2026-08-19 10:42:23 -04:00
Milo Schwartz 21eb4d2876 Merge pull request #3598 from shleeable/patch-12
Fix: ensuring only internal users can request password resets.
2026-08-19 10:35:52 -04:00
Milo Schwartz bb824a7070 Merge pull request #3541 from fosrl/feat/test-alert-button
Feat: test alert rules
2026-08-19 10:33:16 -04:00
Milo Schwartz f98de98288 Merge pull request #3527 from fosrl/feat/log-audit-fails
Fix: log access depends on the wrong org log retention settings field
2026-08-19 10:29:04 -04:00
miloschwartz 350de6ad2e add alert warning for configure coding agents 2026-08-19 10:25:06 -04:00
Owen dc8e724482 Fix issue with subnets in migrations 2026-08-19 09:30:02 -04:00
miloschwartz fb5b564c00 improve expanded client config sections 2026-08-18 17:43:11 -04:00
Owen Schwartz 18270381c1 Merge pull request #3607 from fosrl/dependabot/npm_and_yarn/multi-ff8097116b
Bump sharp, next and @react-email/ui
2026-08-18 17:21:25 -04:00
Owen b32dc30050 Also look for niceId 2026-08-18 17:18:25 -04:00
Owen 667804043c Implement cli commands to configure ai clients 2026-08-18 17:12:01 -04:00
Owen 3fd3d90c10 add the logos 2026-08-18 17:12:01 -04:00
miloschwartz 6e3619fb1a fix mobile responsiveness issues for charts 2026-08-18 16:27:33 -04:00
miloschwartz 987c0e992b make budgets input mobile responsive 2026-08-18 16:11:49 -04:00
miloschwartz bdd7f40688 batch identity key emails 2026-08-18 16:00:07 -04:00
Owen 809bc662e0 Update aiClientConfig 2026-08-18 15:38:23 -04:00
Owen a74e1e765a Pass 3 - add commands, remove run, formatting, mobile view 2026-08-18 14:59:29 -04:00
Owen c356af3eda Fix spacing issue 2026-08-18 14:59:29 -04:00
Owen a4c7121b93 Pass 2 showing the usage commands 2026-08-18 14:59:29 -04:00
Owen 2a3c00045f Pass 1 of the config instructions 2026-08-18 14:59:28 -04:00
dependabot[bot] 8e938a2723 Bump sharp, next and @react-email/ui
Bumps [sharp](https://github.com/lovell/sharp) to 0.35.3 and updates ancestor dependencies [sharp](https://github.com/lovell/sharp), [next](https://github.com/vercel/next.js) and [@react-email/ui](https://github.com/resend/react-email/tree/HEAD/packages/ui). These dependencies need to be updated together.


Updates `sharp` from 0.34.5 to 0.35.3
- [Release notes](https://github.com/lovell/sharp/releases)
- [Commits](https://github.com/lovell/sharp/compare/v0.34.5...v0.35.3)

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

Updates `@react-email/ui` from 6.5.0 to 6.9.2
- [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.2/packages/ui)

---
updated-dependencies:
- dependency-name: sharp
  dependency-version: 0.35.3
  dependency-type: indirect
- dependency-name: next
  dependency-version: 16.3.1
  dependency-type: direct:production
- dependency-name: "@react-email/ui"
  dependency-version: 6.9.2
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-18 16:26:28 +00:00
Owen Schwartz 197f8f7ba5 Merge pull request #3503 from fosrl/dependabot/npm_and_yarn/engine.io-6.6.9
Bump engine.io from 6.6.7 to 6.6.9
2026-08-18 12:25:27 -04:00
Owen Schwartz 10b528642d Merge pull request #3517 from fosrl/dependabot/github_actions/github-actions-dependencies-7eb9e48ea9
Bump the github-actions-dependencies group across 1 directory with 2 updates
2026-08-18 12:24:54 -04:00
Owen Schwartz 60d6fff085 Merge pull request #3525 from fosrl/dependabot/npm_and_yarn/ip-address-10.4.0
Bump ip-address from 10.2.0 to 10.4.0
2026-08-18 12:24:30 -04:00
Owen Schwartz c664b3da91 Merge pull request #3526 from fosrl/dependabot/npm_and_yarn/multi-5e81c1b34f
Bump brace-expansion
2026-08-18 12:24:17 -04:00
Owen Schwartz 492282e758 Merge pull request #3530 from fosrl/dependabot/npm_and_yarn/socket.io-parser-4.2.7
Bump socket.io-parser from 4.2.6 to 4.2.7
2026-08-18 12:24:03 -04:00
Owen Schwartz 47f4aefc25 Merge pull request #3531 from fosrl/dependabot/npm_and_yarn/fast-uri-3.1.5
Bump fast-uri from 3.1.4 to 3.1.5
2026-08-18 12:23:43 -04:00
Owen Schwartz 7c0ff9ede7 Merge pull request #3569 from fosrl/dependabot/npm_and_yarn/js-yaml-4.3.1
Bump js-yaml from 4.3.0 to 4.3.1
2026-08-18 12:23:32 -04:00
Owen Schwartz 44e81ea979 Merge pull request #3570 from fosrl/dependabot/npm_and_yarn/nanoid-3.3.18
Bump nanoid from 3.3.12 to 3.3.18
2026-08-18 12:23:17 -04:00
Owen Schwartz 56dc10330a Merge pull request #3572 from fosrl/dependabot/npm_and_yarn/postcss-8.5.23
Bump postcss from 8.5.15 to 8.5.23
2026-08-18 12:23:03 -04:00
Owen Schwartz 6c6e5c0fdf Merge pull request #3605 from fosrl/crowdin_dev
New Crowdin updates
2026-08-18 12:21:47 -04:00
Owen 4be73558d5 Restrict wildcard domains for inference resources 2026-08-18 11:30:44 -04:00
Owen 87ff7650a1 Show a - on the resource column when no resource is found 2026-08-18 11:29:37 -04:00
Owen Schwartz ae5af013b4 New translations en-us.json (French)
[ci skip]
2026-08-18 11:13:09 -04:00
Owen Schwartz 5adfe65aba New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-08-18 11:13:07 -04:00
Owen Schwartz d3ae06deeb New translations en-us.json (Chinese Simplified)
[ci skip]
2026-08-18 11:13:05 -04:00
Owen Schwartz ee4a72bcb7 New translations en-us.json (Turkish)
[ci skip]
2026-08-18 11:13:03 -04:00
Owen Schwartz f941c23927 New translations en-us.json (Russian)
[ci skip]
2026-08-18 11:13:00 -04:00
Owen Schwartz 3ae6a08b71 New translations en-us.json (Portuguese)
[ci skip]
2026-08-18 11:12:58 -04:00
Owen Schwartz 620eeaed4c New translations en-us.json (Polish)
[ci skip]
2026-08-18 11:12:56 -04:00
Owen Schwartz 9b08228398 New translations en-us.json (Dutch)
[ci skip]
2026-08-18 11:12:53 -04:00
Owen Schwartz eb102a0d23 New translations en-us.json (Korean)
[ci skip]
2026-08-18 11:12:50 -04:00
Owen Schwartz 18317e0028 New translations en-us.json (Italian)
[ci skip]
2026-08-18 11:12:48 -04:00
Owen Schwartz 2ec93c6710 New translations en-us.json (German)
[ci skip]
2026-08-18 11:12:46 -04:00
Owen Schwartz 5933eeb05f New translations en-us.json (Danish)
[ci skip]
2026-08-18 11:12:43 -04:00
Owen Schwartz 3fae2d687f New translations en-us.json (Czech)
[ci skip]
2026-08-18 11:12:40 -04:00
Owen Schwartz 285d7c6030 New translations en-us.json (Bulgarian)
[ci skip]
2026-08-18 11:12:38 -04:00
Owen Schwartz 0fd57f5843 New translations en-us.json (Spanish)
[ci skip]
2026-08-18 11:12:36 -04:00
Owen a296460124 Move out of local directory; fixes turbopack 2026-08-18 10:39:13 -04:00
Owen 35104f7b29 Add AI Provider and Virtual API Key actions to command palette 2026-08-18 10:19:56 -04:00
Owen 51375ed8b7 Fix key issue 2026-08-18 09:59:42 -04:00
Owen 1bb4f604e9 Fix select from missing new columns 2026-08-18 09:52:24 -04:00
Owen Schwartz 8fd990df93 New translations en-us.json (French)
[ci skip]
2026-08-17 18:29:37 -04:00
Owen Schwartz 1f390cabc6 New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-08-17 18:29:35 -04:00
Owen Schwartz 7f1f8c54c1 New translations en-us.json (Chinese Simplified)
[ci skip]
2026-08-17 18:29:33 -04:00
Owen Schwartz b1515db229 New translations en-us.json (Turkish)
[ci skip]
2026-08-17 18:29:31 -04:00
Owen Schwartz d78c226734 New translations en-us.json (Russian)
[ci skip]
2026-08-17 18:29:30 -04:00
Owen Schwartz 52865bc1f5 New translations en-us.json (Portuguese)
[ci skip]
2026-08-17 18:29:28 -04:00
Owen Schwartz efbb6c9814 New translations en-us.json (Polish)
[ci skip]
2026-08-17 18:29:26 -04:00
Owen Schwartz 1e3dea3dad New translations en-us.json (Dutch)
[ci skip]
2026-08-17 18:29:24 -04:00
Owen Schwartz 25f84482ba New translations en-us.json (Korean)
[ci skip]
2026-08-17 18:29:22 -04:00
Owen Schwartz 3f2a116fdb New translations en-us.json (Italian)
[ci skip]
2026-08-17 18:29:20 -04:00
Owen Schwartz dce002c6cc New translations en-us.json (German)
[ci skip]
2026-08-17 18:29:18 -04:00
Owen Schwartz dcfbbdc79d New translations en-us.json (Danish)
[ci skip]
2026-08-17 18:29:16 -04:00
Owen Schwartz f8cac37eef New translations en-us.json (Czech)
[ci skip]
2026-08-17 18:29:14 -04:00
Owen Schwartz cb37713c4c New translations en-us.json (Bulgarian)
[ci skip]
2026-08-17 18:29:12 -04:00
Owen Schwartz 7ae4082d7e New translations en-us.json (Spanish)
[ci skip]
2026-08-17 18:29:11 -04:00
Owen aa8d339d35 Adjust sudo mode on the roles to full for use of ssh 2026-08-17 17:24:16 -04:00
Owen 7a61b182e0 Deduplicate inference resources by fullDomain to prevent duplicate routers 2026-08-17 17:13:22 -04:00
Owen 91b697b80b Track public inference domains to avoid duplicate routers for siteResource aliases 2026-08-17 17:06:37 -04:00
Owen 7d10b63dc5 Add v1 migrations 2026-08-17 16:50:51 -04:00
Owen 92916cd9db Remove wrong named 1.21.1 migration for 1.22 2026-08-17 16:01:30 -04:00
Owen 075d112861 Add base migrations 2026-08-17 15:54:42 -04:00
Owen 7e9407fbc7 Don't generate ai resource for remote nodes 2026-08-17 15:48:32 -04:00
miloschwartz 313f777e44 rename routes from inference to ai-gateway 2026-08-17 15:38:43 -04:00
Owen 3ba9eb7ad7 Default form to full sudo mode 2026-08-17 15:11:03 -04:00
Owen 328a2d52da Remove defaults from private config for legacy acme 2026-08-17 15:01:26 -04:00
miloschwartz 697bea814e bump version 2026-08-17 15:00:18 -04:00
Owen 83cda218b7 Merge branch 'free-me-up' into aig 2026-08-17 14:54:05 -04:00
Owen 76435440af Fix disabled sections in the ui 2026-08-17 14:53:20 -04:00
miloschwartz 0a8b5e46ca Bind share link sessions to the token's own resource 2026-08-17 14:24:21 -04:00
Owen a74b0934fc Allow changing the allow ssh and update shcmea 2026-08-17 14:12:39 -04:00
Owen c05d2aeafb Update lock 2026-08-17 14:06:40 -04:00
Owen 87a3e5ceea Generate the cert properly 2026-08-17 13:45:51 -04:00
Owen 555b36e13e Fix target pulling still being private 2026-08-17 13:40:09 -04:00
Owen 021866011f Update lock 2026-08-17 12:03:54 -04:00
Owen 749abdcb56 Fix circular import issue 2026-08-17 12:00:13 -04:00
Owen 7d6f3a2925 Refactor license key validation and recheck logic to improve error handling and maintain cache integrity 2026-08-17 12:00:13 -04:00
Owen af515f1072 Pass 2 bring over browser resources and http resource cert gen 2026-08-17 12:00:12 -04:00
Owen c90c67eab3 Pass 1 of pulling traefik config into functions 2026-08-17 12:00:12 -04:00
Owen 51c9507d08 Show the cert status on the frontend 2026-08-17 12:00:12 -04:00
Owen 11daf4f927 Move certificates 2026-08-17 12:00:12 -04:00
Owen 58195b5959 Move the acme cert sync 2026-08-17 12:00:12 -04:00
Owen b51aecf45a Remove advanced resources paywall 2026-08-17 12:00:12 -04:00
dependabot[bot] eb8ad6a181 Bump the github-actions-dependencies group across 1 directory with 2 updates
Bumps the github-actions-dependencies group with 2 updates in the / directory: [docker/login-action](https://github.com/docker/login-action) and [actions/stale](https://github.com/actions/stale).


Updates `docker/login-action` from 4.5.1 to 4.6.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/abd2ef45e78c5afb21d64d4ca52ee8550d9572c7...dbcb813823bdd20940b903addbd779551569679f)

Updates `actions/stale` from 10.4.0 to 11.0.0
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/1e223db275d687790206a7acac4d1a11bd6fe629...4391f3da665fdf50b6810c1a66712fb9ba21aa93)

---
updated-dependencies:
- dependency-name: actions/stale
  dependency-version: 11.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions-dependencies
- dependency-name: docker/login-action
  dependency-version: 4.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 01:34:14 +00:00
Shlee 4edd2e4d32 Update requestPasswordReset.ts 2026-08-16 22:21:37 +09:30
Shlee 813c3abe54 Update requestPasswordReset.ts 2026-08-16 22:03:00 +09:30
miloschwartz ca7ea72ff1 restyle domain picker 2026-08-14 17:53:53 -04:00
miloschwartz 53b1d8a9f3 option to send identity keys in email 2026-08-14 17:37:17 -04:00
miloschwartz a3dfb30a42 send email upon generate virtual api key 2026-08-14 16:51:27 -04:00
Owen 111d4b1b8c Make sure certs are created when creating and updating private resources 2026-08-14 14:33:59 -04:00
Owen 32d76ee2ac Resolve cert not generated without a provider 2026-08-14 14:27:14 -04:00
Owen 98ad6fb31b Merge branch 'aig' of github.com:fosrl/pangolin into aig 2026-08-14 14:21:04 -04:00
Owen 3a18a3994f Fix private resource ssl when applying through blueprints 2026-08-14 14:21:00 -04:00
Owen 6d35b20880 Fix overlap check 2026-08-14 14:14:56 -04:00
miloschwartz 173d4a536f visual tweaks 2026-08-14 13:45:52 -04:00
Fred KISSIE ea740e12d1 💄 nice little animation on alert rule field 2026-08-14 19:07:13 +02:00
miloschwartz d7bff686c0 add help banner to providers and virtual api keys 2026-08-14 12:24:00 -04:00
Owen 193f6da8d1 Handle the list of models without allow/block 2026-08-14 12:23:20 -04:00
Owen 954dc8d1d9 Handle overlapping fullDomain properly 2026-08-14 12:23:20 -04:00
Owen 234819f8f2 Dont load private resource inference without a exit node loaded 2026-08-14 12:23:20 -04:00
miloschwartz b34a8d116b improve org selector 2026-08-14 12:18:55 -04:00
miloschwartz 7375b9efae improve model picker 2026-08-14 11:56:02 -04:00
Owen c8f170d197 Allow budgets to be set on the resources 2026-08-14 11:43:01 -04:00
Owen 4989d1e31a Allow overlapping domains on public inference resources 2026-08-14 11:43:01 -04:00
miloschwartz c568251d8d improve model picker 2026-08-14 11:38:11 -04:00
miloschwartz db2de4af89 improve usage analytics filters 2026-08-14 11:11:52 -04:00
Owen 574ae8f5f9 Include inference support in blueprints 2026-08-14 10:51:59 -04:00
Owen 125091d719 Send email whitelist user downstream headers
Fix #1226
2026-08-14 10:51:59 -04:00
Owen 5d37f44241 Add niceid to ui and api for providers 2026-08-14 10:51:58 -04:00
Owen f0f5e9219b add niceId to provider 2026-08-14 10:51:58 -04:00
miloschwartz ee75a09f8c improve sign in url style 2026-08-14 10:33:44 -04:00
miloschwartz c49c6f5837 improve budget editor component 2026-08-14 10:27:17 -04:00
Owen 03118f1ede Support labels on blueprints
Ref https://github.com/orgs/fosrl/discussions/2849
2026-08-14 09:16:50 -04:00
miloschwartz 0ff5ea4f6a strip settings from tab name 2026-08-13 17:08:37 -04:00
Owen 978fa8bfc6 Fix refresh buttons and dont auto refresh
Fixes #3560
2026-08-13 16:57:43 -04:00
Owen 36c7c5dc34 Rename AI tables to not have ai in the name 2026-08-13 16:57:43 -04:00
miloschwartz 8f3628d4ec rename inference to ai gateway 2026-08-13 16:57:29 -04:00
miloschwartz fcd3c72cac add missing routes to integration api 2026-08-13 16:35:47 -04:00
Owen 3f0be4a39d Filter out unhealthy targets 2026-08-13 16:32:04 -04:00
miloschwartz f9ff3a5715 improve resource type picker 2026-08-13 15:52:33 -04:00
miloschwartz ed71c90d73 move ai logs under gateway section 2026-08-13 15:28:22 -04:00
miloschwartz e580c7db7c use pangolin-key prefix for virtual api keys 2026-08-13 15:22:18 -04:00
miloschwartz 4a9b0117a2 copy key without clicking reveal first 2026-08-13 15:16:39 -04:00
miloschwartz 9b10292e02 add model picker to create provider wizard 2026-08-13 15:08:10 -04:00
miloschwartz d9a9ae14fd various ui improvements 2026-08-13 14:54:09 -04:00
Owen db716f781e Revert "fix logo warning in logs"
This reverts commit 355294a2d4.
2026-08-13 13:51:11 -04:00
Fred KISSIE 46f341f7fd 🏷️ fix types 2026-08-13 19:26:15 +02:00
Fred KISSIE 9bb413bb1e ♻️ refactor 2026-08-13 19:23:39 +02:00
Fred KISSIE 2878d5690c 💬 update texts for heading & trigger 2026-08-13 19:19:55 +02:00
Fred KISSIE 1143404a65 💄alert rule popover 2026-08-13 19:15:23 +02:00
Owen e92bb9043b Fix issues with possible null providerId 2026-08-13 11:04:11 -04:00
Owen a2f5d830d6 Remove not null constraint where setting null 2026-08-13 10:20:35 -04:00
miloschwartz 77e3422e88 change provider descriptions and dont set default url for bedrock 2026-08-13 10:13:32 -04:00
miloschwartz 9a551c91e5 always require upstream url 2026-08-13 10:07:11 -04:00
Owen c35ac493ed Rebalance the data in the expand column 2026-08-13 09:38:42 -04:00
Owen e100645a00 Use the right subnet 2026-08-13 09:38:42 -04:00
miloschwartz 96bfc66a94 set default provider name 2026-08-13 09:37:53 -04:00
miloschwartz e21c9eec31 Merge branch 'aig' of https://github.com/fosrl/pangolin into aig 2026-08-13 09:29:02 -04:00
miloschwartz a9f32102b5 format custom error codes according to capability 2026-08-13 09:26:58 -04:00
Owen 7291628f86 optionally stamp ip header for downstream use 2026-08-13 09:14:37 -04:00
Owen 5fbb205044 search the right resource 2026-08-12 17:52:39 -04:00
Owen 02ab24d01e Fix clients not connecting on first connect 2026-08-12 17:16:59 -04:00
Owen 3cfbd66a80 Merge branch 'dev' into aig 2026-08-12 16:34:52 -04:00
Owen Schwartz 048e4fc73c Merge pull request #3574 from fosrl/dev
1.21.1-s.5
2026-08-12 16:34:31 -04:00
Owen 860fa47b7c Split out the token header into a common middleware for efficiency 2026-08-12 16:30:42 -04:00
miloschwartz 71d9d8f010 fix accept invite as idp user 2026-08-12 16:25:20 -04:00
Owen cc58e28e54 Allow merging a catalog file with the api response 2026-08-12 16:06:12 -04:00
Owen 653dd920ad Allow remote header overrides and fix display issues with log 2026-08-12 15:54:22 -04:00
Fred KISSIE 49b4fcf063 ♻️ refactor 2026-08-12 21:36:40 +02:00
miloschwartz dd78c2cc08 dont show servers idps in create user when idp mode is org on enterprise 2026-08-12 15:20:41 -04:00
Owen 47ae017f94 add virtual api keys to budgets 2026-08-12 13:21:56 -04:00
Fred KISSIE 4b61e12ca6 ♻️ trigger alert correctly 2026-08-12 18:56:56 +02:00
Fred KISSIE c6bd657ee6 send webhook action 2026-08-12 18:48:14 +02:00
miloschwartz c33fa8782b Merge branch 'aig' of https://github.com/fosrl/pangolin into aig 2026-08-12 12:35:30 -04:00
miloschwartz 93cba1d098 support sending capability specific error codes 2026-08-12 12:34:25 -04:00
Owen d3a1f9798d Merge branch 'aig' of github.com:fosrl/pangolin into aig 2026-08-12 11:45:28 -04:00
miloschwartz 115c3cbf07 show sso page in browser for gateway resource 2026-08-12 11:32:22 -04:00
Owen bed5817da3 send, process, store, display virtual api key ai information in usage and sessions 2026-08-12 11:20:25 -04:00
miloschwartz 83035753af support bypass auth rules on gateway 2026-08-12 11:18:16 -04:00
miloschwartz 379b53bcca redirect to api key page when access gateway in browser 2026-08-12 11:15:50 -04:00
Owen 355294a2d4 fix logo warning in logs 2026-08-12 10:48:06 -04:00
Owen 833b27ab4a separate the users and roles tabs 2026-08-12 10:40:38 -04:00
Owen 8a04f13dd4 translate keys and restructure roles and users 2026-08-12 10:40:38 -04:00
miloschwartz 49020fa6ea add virtual api key validation in verifySession 2026-08-12 10:38:26 -04:00
Owen ac3402a8b3 Adjust structure delete models 2026-08-12 09:41:42 -04:00
Owen 9369e60695 Merge branch 'dev' into aig 2026-08-12 09:19:12 -04:00
Owen 295e38d2af Remove arbitrary max 50 limit from email whitelist
Fix #3568
2026-08-12 09:11:10 -04:00
dependabot[bot] 923371e5b4 Bump postcss from 8.5.15 to 8.5.23
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.15 to 8.5.23.
- [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.15...8.5.23)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.23
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-12 02:57:16 +00:00
dependabot[bot] 81430ba3d3 Bump nanoid from 3.3.12 to 3.3.18
Bumps [nanoid](https://github.com/ai/nanoid) from 3.3.12 to 3.3.18.
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/3.3.18/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/3.3.12...3.3.18)

---
updated-dependencies:
- dependency-name: nanoid
  dependency-version: 3.3.18
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-11 23:54:36 +00:00
Owen 11595f5f96 use title case and fix colors 2026-08-11 18:02:17 -04:00
Owen b08e875b37 basic ai analytics created 2026-08-11 17:52:27 -04:00
Owen 0016b8fce7 show other tokens on the expanded row 2026-08-11 16:40:24 -04:00
Owen f5b10df7cf fix non-https crypto issue 2026-08-11 16:40:24 -04:00
miloschwartz 1782f31075 show link to provider settings 2026-08-11 16:38:22 -04:00
Fred KISSIE 21032bc22b test alert email works 2026-08-11 22:25:26 +02:00
dependabot[bot] e4aaadc9f9 Bump js-yaml from 4.3.0 to 4.3.1
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.3.0 to 4.3.1.
- [Changelog](https://github.com/nodeca/js-yaml/blob/4.3.1/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.3.0...4.3.1)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.3.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-11 20:19:18 +00:00
Owen f7aca85417 link the usage with the session to display together 2026-08-11 16:15:01 -04:00
miloschwartz c42df737b0 improve model provider selection algorithm 2026-08-11 16:10:46 -04:00
Owen 75c6af3b11 Merge branch 'aig' of github.com:fosrl/pangolin into aig 2026-08-11 15:58:12 -04:00
Owen e734cc93a1 small visual adjustments and chat button 2026-08-11 15:57:57 -04:00
Owen e3ccc4f8d4 show the sessions in the ui 2026-08-11 15:50:51 -04:00
miloschwartz 896c362dce only support sso on inference resource auth policy 2026-08-11 15:35:28 -04:00
miloschwartz 864ab97293 hide expire column 2026-08-11 14:59:03 -04:00
Fred KISSIE 899c47e9a3 🚧 write process test alert function 2026-08-11 20:52:13 +02:00
Owen bc2f291352 normalize the requests to also store in the log for viewing later 2026-08-11 14:45:25 -04:00
miloschwartz 98f5e39a7f show ai gateway resource details in launcher 2026-08-11 14:42:07 -04:00
Owen e0a66e79bb Continue to quiet log messages 2026-08-11 14:07:33 -04:00
Owen c781be4e70 quiet up some more logs 2026-08-11 14:05:58 -04:00
Owen 48c4b44f72 log chat sessions to database 2026-08-11 12:19:28 -04:00
Owen 68a84181d9 remove excess logging 2026-08-11 12:19:27 -04:00
Owen b60390d805 batch up usage records for insert 2026-08-11 12:19:27 -04:00
miloschwartz b33e776072 add virtual keys link in header on launcher 2026-08-11 12:09:11 -04:00
miloschwartz 2187f23588 add page to retrieve user virtual api keys 2026-08-11 11:53:39 -04:00
miloschwartz 732fd4eba1 allow retrieving api key secret 2026-08-11 10:47:04 -04:00
Owen 495c772d6b Merge branch 'dev' into aig 2026-08-11 09:42:10 -04:00
Owen 02e4fe8b48 Show the provided domains when changing properly 2026-08-11 09:40:55 -04:00
miloschwartz 6d45486bb5 add manual virtual api key create ui 2026-08-10 21:44:38 -04:00
Owen 4c1f7f6243 add caching to budget check 2026-08-10 17:39:00 -04:00
miloschwartz 1e3a9fb921 add virtual api key schema and crud endpoints 2026-08-10 17:20:06 -04:00
Owen 8b8e7913dc budget enforcement logic 2026-08-10 17:17:24 -04:00
Owen 88515a7dad remove duplicate save buttons 2026-08-10 15:31:45 -04:00
Owen e012063f5b add the budget to the models on the provider 2026-08-10 15:09:39 -04:00
Owen e76377d3c7 Match the pg query logging 2026-08-10 14:55:44 -04:00
Owen a357f42c48 allow creating role with budgets 2026-08-10 14:55:44 -04:00
Owen 2becb15916 put the budget on the role form 2026-08-10 14:55:44 -04:00
miloschwartz 186eeed784 add richer model editor on provider 2026-08-10 14:54:58 -04:00
Owen 216c932cb9 order by budget id 2026-08-10 14:12:34 -04:00
Owen 52de5eb539 add the budget config to the resources 2026-08-10 14:08:17 -04:00
Owen eec3119297 Merge branch 'aig' of github.com:fosrl/pangolin into aig 2026-08-10 13:55:10 -04:00
Owen f2e7f83b3d match the budget picker to the rules picker 2026-08-10 13:55:05 -04:00
miloschwartz 93dea0525e basic provider model selector 2026-08-10 12:32:17 -04:00
Owen 7e6e0a8a9c make the budget match the rules 2026-08-10 12:20:47 -04:00
Owen 211d3a53f5 initial budget ui on the provider 2026-08-10 12:03:51 -04:00
miloschwartz 187936e5dd use class with helper methods for model catelog 2026-08-10 11:54:57 -04:00
Owen b180d064d1 adjust conflict to be on unit<>period and add list endpoints for providers 2026-08-10 11:39:34 -04:00
Owen ed46afd81a add subscription override column 2026-08-10 11:15:48 -04:00
Owen 0050fad70d add basic crud for ai budgets 2026-08-10 11:05:10 -04:00
miloschwartz 3dc9c100e9 better form feedback when saving roles 2026-08-10 11:02:44 -04:00
Owen 969e7c9296 add crud endpoint skill 2026-08-10 10:58:11 -04:00
Owen 591caab45a Add documentation for creating new crud endpoints 2026-08-10 10:47:18 -04:00
miloschwartz 02e97d6ae4 add copy button to org id 2026-08-10 10:44:25 -04:00
miloschwartz 996160fadc add missing resource policy actions to api key closes #3542 2026-08-10 10:38:40 -04:00
Owen dc1923ab1f Remove budget periods 2026-08-10 09:57:01 -04:00
Owen fe5831eb48 pull the models from the upstream server 2026-08-10 09:57:00 -04:00
Owen ca79abc9d4 Update structure 2026-08-10 09:57:00 -04:00
miloschwartz 9eafa067b9 improved provider picker ui on resource 2026-08-07 16:13:51 -04:00
Fred KISSIE 403b8a12e4 🚧 wip 2026-08-07 20:57:54 +02:00
Fred KISSIE 3f305e4d5c 🚧 process test alert 2026-08-07 20:52:25 +02:00
miloschwartz 5e5e172d39 add streaming function to capability 2026-08-07 14:36:15 -04:00
miloschwartz bc7a883f6c add some parallelization to ai gateway pipeline 2026-08-07 14:36:11 -04:00
Owen 12056aebc6 Add basic cost calculations for testing 2026-08-07 13:54:32 -04:00
Fred KISSIE 6689a8d93e 🚧 wip: test alert rule 2026-08-07 19:05:28 +02:00
miloschwartz 297cb9c8f2 set provider default capabilities 2026-08-07 12:25:29 -04:00
miloschwartz d699455b38 support changing capabilities on provider integrations 2026-08-07 11:23:07 -04:00
miloschwartz 9e7b4afaec support allow list and blocklist 2026-08-07 10:59:50 -04:00
Owen e91c344e64 Update link to be correct 2026-08-07 10:21:15 -04:00
Owen 07f628b928 add the pangolin header information 2026-08-06 17:53:51 -04:00
Owen 184e1425a4 Private connection working with traefik 2026-08-06 17:22:01 -04:00
Owen 22f2990f56 support streaming and closes properly on site targets 2026-08-06 17:15:11 -04:00
Owen 6c28c5f383 Basic target routing 2026-08-06 15:48:13 -04:00
Owen e9f9cf54f4 Include the sheme in the routing header 2026-08-06 15:18:11 -04:00
Owen 751d1b083d Add back port restrictions 2026-08-06 14:55:07 -04:00
Owen a4d77a4fd3 Fix info box ui 2026-08-06 14:45:25 -04:00
Owen f72252552f Fix saving 2026-08-06 14:28:14 -04:00
miloschwartz c5d68675c9 support patterns in model key 2026-08-06 14:27:08 -04:00
Owen Schwartz d04740fede Merge pull request #3537 from fosrl/dev
1.21.1-s.4
2026-08-06 14:07:42 -04:00
miloschwartz 36b8ef5fba Merge branch 'dev' into aig 2026-08-06 13:59:07 -04:00
Owen 6cca5e0472 Use fulldoman instead of the alias for the site resources 2026-08-06 13:45:51 -04:00
miloschwartz 4048fa274a fix non admins cant see private resources details in launcher 2026-08-06 12:32:19 -04:00
Owen b4d2974e19 Properly configure ssl and domain when creating and editing 2026-08-06 12:20:56 -04:00
Owen fb896d6c0f Add domain to the resource selection 2026-08-06 12:03:40 -04:00
miloschwartz 82b86263dc allow chars in 2fa input form closes #3532 2026-08-06 11:39:06 -04:00
Owen 6564bfe8ae disabled advanced mode on the providers 2026-08-06 11:00:34 -04:00
Owen 75ce7e91d7 Restrict the ai site resource router to exit node subnet 2026-08-06 11:00:34 -04:00
Owen 656eea5bb1 add a target routing provider for custom target resources 2026-08-06 11:00:34 -04:00
miloschwartz aba27a7bbf support skip tls per request 2026-08-05 17:53:28 -04:00
miloschwartz 3d7e322bf9 add headers to provider 2026-08-05 17:42:58 -04:00
miloschwartz 1f3fff4a9d Merge branch 'aig' of https://github.com/fosrl/pangolin into aig 2026-08-05 16:55:58 -04:00
miloschwartz 39e06f2b6d add api capabilities 2026-08-05 16:55:48 -04:00
Owen b775c5b674 include clients in the peer config 2026-08-05 16:24:09 -04:00
Owen 796bf37141 ai gateway override only required sometimes 2026-08-05 16:24:01 -04:00
Owen c5b0e1e876 add QUERY_LOGGING env to sqlite driver for verbose logging 2026-08-05 16:17:28 -04:00
miloschwartz 2e8bd7a8c7 improve form error codes 2026-08-05 15:56:12 -04:00
miloschwartz 790daba796 allow no provider on a resource 2026-08-05 15:43:42 -04:00
miloschwartz bcf6b86b84 add no auth and passthrough auth 2026-08-05 15:39:08 -04:00
miloschwartz 2e9bd50172 add provider specific auth modes 2026-08-05 15:17:50 -04:00
dependabot[bot] 4677a0d501 Bump fast-uri from 3.1.4 to 3.1.5
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.4 to 3.1.5.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.4...v3.1.5)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-05 18:18:41 +00:00
miloschwartz c673dce484 pin docker base image 2026-08-05 13:34:45 -04:00
Owen e1dd8965dc Remove extra checks 2026-08-05 11:31:12 -04:00
Owen 346a0bdc98 Resolve syntax issue 2026-08-05 10:31:14 -04:00
Owen 8c1169738e Merge branch 'aig' of github.com:fosrl/pangolin into aig 2026-08-05 10:24:42 -04:00
miloschwartz bc80f91a45 gateway endpoint mvp 2026-08-05 10:24:37 -04:00
Owen 3d062389e9 update oss with gateway traefik routers 2026-08-05 10:14:40 -04:00
Owen 7afddb5eb5 Clean logging 2026-08-05 10:00:36 -04:00
Owen 425a99e5ee Show the auth settings for testing 2026-08-05 09:59:41 -04:00
Owen a5e9339af9 Merge branch 'dev' into aig 2026-08-05 09:32:03 -04:00
dependabot[bot] b4463f0e1a Bump socket.io-parser from 4.2.6 to 4.2.7
Bumps [socket.io-parser](https://github.com/socketio/socket.io) from 4.2.6 to 4.2.7.
- [Release notes](https://github.com/socketio/socket.io/releases)
- [Changelog](https://github.com/socketio/socket.io/blob/main/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io/compare/socket.io-parser@4.2.6...socket.io-parser@4.2.7)

---
updated-dependencies:
- dependency-name: socket.io-parser
  dependency-version: 4.2.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-05 06:42:16 +00:00
Owen Schwartz b7c0669c38 Merge pull request #3528 from fosrl/dev
1.21.1-s.3
2026-08-04 17:46:49 -04:00
Owen 835a30cffe Show the cert status of the namespace domains properly 2026-08-04 17:44:34 -04:00
miloschwartz 83e20c2dfa adjust placeholder ui for rules and targets 2026-08-04 17:36:00 -04:00
miloschwartz f85d41945a Merge branch 'aig' of https://github.com/fosrl/pangolin into aig 2026-08-04 17:27:10 -04:00
miloschwartz 149eb17b27 create basic public inference resource 2026-08-04 17:26:57 -04:00
Owen 18b90da6ab Merge branch 'main' into dev 2026-08-04 17:22:32 -04:00
Owen 72d469b19c Revert changes to traefik config to pull site resources 2026-08-04 17:17:51 -04:00
Owen 6526d7f225 Remove completions endpoints 2026-08-04 17:09:44 -04:00
Owen 36c0edc62e Use p host to pass the host header 2026-08-04 17:08:59 -04:00
Owen aad26b9ae4 move the ai gateway to its own server 2026-08-04 17:08:59 -04:00
Owen 80dcdfe251 change override to work for bother badger and ai gateway 2026-08-04 17:08:59 -04:00
miloschwartz 1696fc37a8 dont set port restrictions for inference 2026-08-04 17:04:29 -04:00
miloschwartz 7759d87835 add basic ui for private inference resource 2026-08-04 16:54:23 -04:00
miloschwartz c085de1e9e Merge branch 'aig' of https://github.com/fosrl/pangolin into aig
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 15:56:27 -04:00
Fred KISSIE e99cd52e99 🐛 Fix log retention access pulling from the wrong org settings field 2026-08-04 21:55:34 +02:00
miloschwartz e38359c74f add crud for adding providers and models to resources 2026-08-04 15:54:24 -04:00
Owen 2cfd7e867b delete the client peers from the exit nodes properly 2026-08-04 15:46:22 -04:00
Owen ec5a2b0cbe add peers to exit node 2026-08-04 15:46:22 -04:00
Owen 2bf426bc22 dont restrict to enterprise and dont link with providers yet 2026-08-04 15:46:21 -04:00
miloschwartz ed8545f8a2 update schema to support models and provider access on inferences resources and add budgets 2026-08-04 14:50:07 -04:00
Owen 9811492a0b send the aliases when connecting for the first time 2026-08-04 14:23:25 -04:00
Owen 973925b35d allow creating basic inference resource 2026-08-04 14:22:36 -04:00
Owen 0b30cfc341 Working on inference resource management 2026-08-04 11:48:36 -04:00
Owen a7e44944fb Use ip to find user 2026-08-04 10:43:49 -04:00
Owen 9b25ebd6cd remove token fallback 2026-08-04 10:19:22 -04:00
miloschwartz 33b775e263 clean up providers ui 2026-08-04 10:15:49 -04:00
miloschwartz b0edc6302e providers table, create, and edit first pass 2026-08-04 10:15:49 -04:00
Owen 1a644b131d Pull the session cookie properly 2026-08-04 10:15:49 -04:00
Owen f7689b7a5a Endpoints to update models on the resource 2026-08-04 10:15:49 -04:00
Owen 1073011a2a first pass of traefik -> basic gateway 2026-08-04 10:15:49 -04:00
miloschwartz 6fa0009ebf add targets and refactor endpoints 2026-08-04 10:15:49 -04:00
Owen 42c0abedb7 Add basic page to configure the alias 2026-08-04 10:15:48 -04:00
Owen 56e59a93f3 Handle the alias create and update special case exit nodes 2026-08-04 10:15:48 -04:00
Owen 9d581f3897 Fix types 2026-08-04 10:15:48 -04:00
miloschwartz e5ac6ec7cd add default providers and support overrides 2026-08-04 10:15:48 -04:00
miloschwartz 694fe98131 add crud for providers and models 2026-08-04 10:15:48 -04:00
miloschwartz 730078597e add basic schema 2026-08-04 10:15:48 -04:00
Owen 32ac8db803 Handle aliases when registering 2026-08-04 10:15:48 -04:00
Owen aad2ed2719 Get exit node every time 2026-08-04 10:15:48 -04:00
Owen a790fed297 sync the exit node connection 2026-08-04 10:15:48 -04:00
Owen deb2d5ce2a Update site resources to handle new inference 2026-08-04 10:15:48 -04:00
Owen 093097c619 send if we need to connect to the gerbil or not 2026-08-04 10:15:48 -04:00
Owen 2cdeb7c104 Add inference resource type 2026-08-04 10:15:47 -04:00
Owen 4dbb04bfb8 Handle the ping exit node request backward mode for olm and install handler 2026-08-04 10:15:47 -04:00
Owen fa8b921635 Fix postgres schema exit node subnet 2026-08-04 10:15:47 -04:00
Owen 33dd10c670 Rename subnet for clarity, pick subnet on client 2026-08-04 10:15:47 -04:00
Owen ba24e1c4f5 Add exit node selection to the clients 2026-08-04 10:15:47 -04:00
Owen f079714caf Dont redirect when the browser agent is not real 2026-08-04 10:07:52 -04:00
dependabot[bot] 152d2fb1d6 Bump brace-expansion
Bumps  and [brace-expansion](https://github.com/juliangruber/brace-expansion). These dependencies needed to be updated together.

Updates `brace-expansion` from 5.0.6 to 5.0.9
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v5.0.6...v5.0.9)

Updates `brace-expansion` from 1.1.14 to 1.1.18
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v5.0.6...v5.0.9)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 5.0.9
  dependency-type: indirect
- dependency-name: brace-expansion
  dependency-version: 1.1.18
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-04 05:33:39 +00:00
dependabot[bot] d374b4f66e Bump ip-address from 10.2.0 to 10.4.0
Bumps [ip-address](https://github.com/beaugunderson/ip-address) from 10.2.0 to 10.4.0.
- [Release notes](https://github.com/beaugunderson/ip-address/releases)
- [Commits](https://github.com/beaugunderson/ip-address/compare/v10.2.0...v10.4.0)

---
updated-dependencies:
- dependency-name: ip-address
  dependency-version: 10.4.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-04 01:45:48 +00:00
Owen efd2792197 bump default rate limit 2026-08-03 17:57:36 -04:00
Owen Schwartz efe22c889c Merge pull request #3522 from fosrl/dev
Move rate linmit to file
2026-08-03 14:35:43 -04:00
Owen 7f2b3eb481 Move rate linmit to file 2026-08-03 14:34:11 -04:00
Owen Schwartz 81be4a35d9 Merge pull request #3521 from fosrl/dev
1.21.1-s.1
2026-08-03 14:11:05 -04:00
Owen e84da6a8df Add rate limits to sensitve pages 2026-08-03 14:08:09 -04:00
miloschwartz 3ef3ede7df dont pass orgId in generate oidc url 2026-08-03 11:41:22 -04:00
Owen 1e521b0b54 Dont double log error 2026-08-03 10:35:26 -04:00
Owen 59ea701304 Merge branch 'dev' of github.com:fosrl/pangolin into dev 2026-07-31 14:23:43 -04:00
miloschwartz 7d7c54107d add first seen and last seen columns to user devices table 2026-07-31 14:23:06 -04:00
Owen Schwartz f0f6673d69 Merge pull request #3511 from fosrl/feat/show-idp-icon-in-smart-login-form
feat: show the correct idp type icon in the last used idp
2026-07-31 09:32:01 -04:00
Owen Schwartz 71561d0e65 Merge pull request #3512 from fosrl/fix/batch-status-hover-data
Fix: resolve batch certificates for NS & Wildcard domains
2026-07-31 09:31:21 -04:00
Fred KISSIE af87edf3a6 ♻️ use const 2026-07-30 22:06:38 +01:00
Fred KISSIE 13caad18c7 🎨 format file 2026-07-30 21:53:46 +01:00
Fred KISSIE 47522b7e3a ♻️ set cert error if initial value is null 2026-07-30 21:53:31 +01:00
Fred KISSIE c8c8d74452 🐛 resolve certificates for non exact domains too 2026-07-30 21:52:51 +01:00
Fred KISSIE e7098963d6 ♻️ Code Refactoring 2026-07-30 17:56:15 +01:00
Fred KISSIE f015fb592b ♻️ Prioritize variant in place of idp.type if available 2026-07-30 17:55:58 +01:00
Fred KISSIE c099167905 Merge branch 'dev' into feat/show-idp-icon-in-smart-login-form 2026-07-30 17:37:47 +01:00
Owen b0e274f5a9 Fix 100% showing on resources with no hc anymore 2026-07-30 11:14:34 -04:00
Owen Schwartz 146c287aba Merge pull request #3505 from fosrl/dev
1.21.1
2026-07-29 21:27:33 -04:00
Owen Schwartz 95ee56217c Merge pull request #3504 from fosrl/crowdin_dev
New Crowdin updates
2026-07-29 21:25:59 -04:00
Owen Schwartz 7625cc208d New translations en-us.json (French)
[ci skip]
2026-07-29 18:14:07 -04:00
Owen Schwartz f2dfd939bc New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-07-29 18:14:05 -04:00
Owen Schwartz 699bc8ddb4 New translations en-us.json (Chinese Simplified)
[ci skip]
2026-07-29 18:14:03 -04:00
Owen Schwartz e658e007e1 New translations en-us.json (Turkish)
[ci skip]
2026-07-29 18:14:01 -04:00
Owen Schwartz 3103265450 New translations en-us.json (Russian)
[ci skip]
2026-07-29 18:13:59 -04:00
Owen Schwartz 97789d9e2e New translations en-us.json (Portuguese)
[ci skip]
2026-07-29 18:13:57 -04:00
Owen Schwartz a1aa3d96d4 New translations en-us.json (Polish)
[ci skip]
2026-07-29 18:13:56 -04:00
Owen Schwartz ae39bc0ecd New translations en-us.json (Dutch)
[ci skip]
2026-07-29 18:13:54 -04:00
Owen Schwartz e4d81aa610 New translations en-us.json (Korean)
[ci skip]
2026-07-29 18:13:52 -04:00
Owen Schwartz f59b524266 New translations en-us.json (Italian)
[ci skip]
2026-07-29 18:13:50 -04:00
Owen Schwartz 58c499acae New translations en-us.json (German)
[ci skip]
2026-07-29 18:13:48 -04:00
Owen Schwartz f22301a1eb New translations en-us.json (Danish)
[ci skip]
2026-07-29 18:13:46 -04:00
Owen Schwartz d6264fb39a New translations en-us.json (Czech)
[ci skip]
2026-07-29 18:13:44 -04:00
Owen Schwartz 08b5a9b34b New translations en-us.json (Bulgarian)
[ci skip]
2026-07-29 18:13:42 -04:00
Owen Schwartz 263976bf41 New translations en-us.json (Spanish)
[ci skip]
2026-07-29 18:13:40 -04:00
Owen Schwartz 0e4cd3a5ba Merge pull request #3414 from fosrl/dependabot/go_modules/install/go-install-dependencies-3804ca7238
Bump golang.org/x/term from 0.44.0 to 0.45.0 in /install in the go-install-dependencies group across 1 directory
2026-07-29 18:00:54 -04:00
dependabot[bot] 192542629f Bump engine.io from 6.6.7 to 6.6.9
Bumps [engine.io](https://github.com/socketio/socket.io) from 6.6.7 to 6.6.9.
- [Release notes](https://github.com/socketio/socket.io/releases)
- [Changelog](https://github.com/socketio/socket.io/blob/main/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io/compare/engine.io@6.6.7...engine.io@6.6.9)

---
updated-dependencies:
- dependency-name: engine.io
  dependency-version: 6.6.9
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-29 21:59:23 +00:00
Owen Schwartz aeed271f3e Merge pull request #3494 from fosrl/dependabot/npm_and_yarn/next-16.2.11
Bump next from 16.2.6 to 16.2.11
2026-07-29 17:58:09 -04:00
Owen Schwartz d41e9bbe68 Merge pull request #3472 from fosrl/dependabot/npm_and_yarn/axios-1.18.0
Bump axios from 1.16.1 to 1.18.0
2026-07-29 17:57:46 -04:00
Owen Schwartz 904a5520b8 Merge pull request #3476 from fosrl/dependabot/npm_and_yarn/body-parser-2.3.0
Bump body-parser from 2.2.2 to 2.3.0
2026-07-29 17:57:34 -04:00
Owen Schwartz 0f43ae4871 Merge pull request #3486 from fosrl/dependabot/npm_and_yarn/fast-uri-3.1.4
Bump fast-uri from 3.1.2 to 3.1.4
2026-07-29 17:57:23 -04:00
Owen Schwartz bd0cf98319 Merge pull request #3489 from fosrl/dependabot/github_actions/github-actions-dependencies-3a33fbbccd
Bump the github-actions-dependencies group across 1 directory with 5 updates
2026-07-29 17:57:07 -04:00
Owen Schwartz fc7d6bbaf9 Merge pull request #3496 from fosrl/dependabot/npm_and_yarn/js-yaml-4.3.0
Bump js-yaml from 4.2.0 to 4.3.0
2026-07-29 17:56:37 -04:00
Owen 1b89c328de Merge branch 'main' into dev 2026-07-29 17:54:59 -04:00
miloschwartz e7f147d4ca fix site status stuck loading sometimes 2026-07-29 17:52:55 -04:00
miloschwartz 41dbc7d5ed remove time logs 2026-07-29 17:45:19 -04:00
miloschwartz 9f297317f2 bump version 2026-07-29 17:33:49 -04:00
Fred KISSIE e0a8721207 🚧 wip: idp type icon in last used idp 2026-07-29 22:29:56 +01:00
Owen Schwartz a69b310109 Merge pull request #3460 from shubhamsinnh/fix/idn-punycode-domain-validation
Fix Punycode domain validation
2026-07-29 17:29:25 -04:00
miloschwartz 864a6a39cd Merge branch 'main' into dev 2026-07-29 17:27:22 -04:00
Milo Schwartz 1882d4df88 Merge pull request #3469 from fosrl/refactor/batch-status-requests
refactor: batch status histories
2026-07-29 17:27:11 -04:00
miloschwartz ef3f71e9b5 Merge branch 'main' into dev 2026-07-29 17:09:54 -04:00
Milo Schwartz 9981c4c035 Merge pull request #3410 from Adityakk9031/#3408
fix: redirect to /auth/initial-setup after hitting auth rate limit (#…
2026-07-29 17:09:40 -04:00
Fred KISSIE bc267b7107 ♻️ refactor 2026-07-29 19:11:16 +01:00
Fred KISSIE 5cbb767e5f Set default certificates for private resources 2026-07-29 19:11:03 +01:00
Fred KISSIE ac79621cae ♻️ Adjust the polling interval to 30s for valid certs 2026-07-29 19:05:33 +01:00
miloschwartz f47c94d05b check idp org ownership on save policy closes #3290 2026-07-29 09:38:15 -04:00
Fred KISSIE 10773432bb ♻️ Batch certificate queries 2026-07-28 20:59:42 +01:00
dependabot[bot] d5a56fb71d Bump js-yaml from 4.2.0 to 4.3.0
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.2.0 to 4.3.0.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.2.0...4.3.0)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.3.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-28 12:24:11 +00:00
dependabot[bot] b5518d029e Bump next from 16.2.6 to 16.2.11
Bumps [next](https://github.com/vercel/next.js) from 16.2.6 to 16.2.11.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.2.6...v16.2.11)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.2.11
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-28 07:10:39 +00:00
Owen 9a9ae649ef Add default to path
Fix #3484
2026-07-27 10:02:32 -04:00
Owen aa6dc67015 Pull the version on the info page and use api 2026-07-27 10:01:12 -04:00
dependabot[bot] 0da96b06ea Bump the github-actions-dependencies group across 1 directory with 5 updates
Bumps the github-actions-dependencies group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [actions/checkout](https://github.com/actions/checkout) | `7.0.0` | `7.0.1` |
| [docker/login-action](https://github.com/docker/login-action) | `4.2.0` | `4.5.1` |
| [actions/setup-go](https://github.com/actions/setup-go) | `6.4.0` | `7.0.0` |
| [actions/setup-node](https://github.com/actions/setup-node) | `6.4.0` | `7.0.0` |
| [actions/stale](https://github.com/actions/stale) | `10.3.0` | `10.4.0` |



Updates `actions/checkout` from 7.0.0 to 7.0.1
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0...3d3c42e5aac5ba805825da76410c181273ba90b1)

Updates `docker/login-action` from 4.2.0 to 4.5.1
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/650006c6eb7dba73a995cc03b0b2d7f5ca915bee...abd2ef45e78c5afb21d64d4ca52ee8550d9572c7)

Updates `actions/setup-go` from 6.4.0 to 7.0.0
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/4a3601121dd01d1626a1e23e37211e3254c1c06c...b7ad1dad31e06c5925ef5d2fc7ad053ef454303e)

Updates `actions/setup-node` from 6.4.0 to 7.0.0
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e...820762786026740c76f36085b0efc47a31fe5020)

Updates `actions/stale` from 10.3.0 to 10.4.0
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899...1e223db275d687790206a7acac4d1a11bd6fe629)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 7.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions-dependencies
- dependency-name: docker/login-action
  dependency-version: 4.5.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: actions/setup-go
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions-dependencies
- dependency-name: actions/setup-node
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions-dependencies
- dependency-name: actions/stale
  dependency-version: 10.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-27 01:34:06 +00:00
dependabot[bot] 6649f15498 Bump fast-uri from 3.1.2 to 3.1.4
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.2 to 3.1.4.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.2...v3.1.4)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-25 12:40:05 +00:00
Fred KISSIE 49854f31a5 🚧 WIP: refactor useCertificate to use tanstack qeury 2026-07-24 21:03:52 +01:00
Fred KISSIE 762c79511b 🚧 Add batchedCertificate queries 2026-07-24 20:59:29 +01:00
Fred KISSIE 6e29ebd649 ♻️ refactor getbatchedcertificate 2026-07-24 20:58:47 +01:00
Fred KISSIE e128b8c282 get batched certificates endpoint 2026-07-24 19:07:37 +01:00
Fred KISSIE 832e382888 🚧 WIP: batch certificates 2026-07-23 22:00:31 +01:00
dependabot[bot] 8a68b899f5 Bump golang.org/x/term
Bumps the go-install-dependencies group with 1 update in the /install directory: [golang.org/x/term](https://github.com/golang/term).


Updates `golang.org/x/term` from 0.44.0 to 0.45.0
- [Commits](https://github.com/golang/term/compare/v0.44.0...v0.45.0)

---
updated-dependencies:
- dependency-name: golang.org/x/term
  dependency-version: 0.45.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-install-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-23 01:33:10 +00:00
Owen Schwartz 71f45b8a80 Merge pull request #3479 from fosrl/dev
1.21.0-s.1
2026-07-22 17:27:34 -04:00
Owen ecf008a8d9 Improve cert retreival and new newt combined certs 2026-07-22 17:22:51 -04:00
Fred KISSIE b3880e5c02 ♻️ refactor 2026-07-22 21:34:13 +01:00
Fred KISSIE 58004a8ec9 ♻️ little refactor 2026-07-22 21:25:31 +01:00
Fred KISSIE b0ff7d2707 ♻️ batch healthcheck status history 2026-07-22 21:06:40 +01:00
Fred KISSIE 262f7bd090 resource status histories 2026-07-22 20:57:32 +01:00
Fred KISSIE b1558b09b1 ♻️ refactor imports 2026-07-22 19:33:00 +01:00
Fred KISSIE a33de8268b 🐛 Get last known events working with sqlite 2026-07-22 19:32:53 +01:00
dependabot[bot] c92d5096c4 Bump body-parser from 2.2.2 to 2.3.0
Bumps [body-parser](https://github.com/expressjs/body-parser) from 2.2.2 to 2.3.0.
- [Release notes](https://github.com/expressjs/body-parser/releases)
- [Changelog](https://github.com/expressjs/body-parser/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/body-parser/compare/v2.2.2...v2.3.0)

---
updated-dependencies:
- dependency-name: body-parser
  dependency-version: 2.3.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-21 23:15:07 +00:00
Fred KISSIE 9867d3c876 ♻️ const instead of let 2026-07-21 20:31:57 +01:00
Fred KISSIE 70bddba55b Make batched status history work 2026-07-21 20:29:16 +01:00
Fred KISSIE 23181f4019 🚧 WIP: batched site status histories 2026-07-21 19:57:09 +01:00
Owen Schwartz 19c1c2042b Merge pull request #3468 from fosrl/fix/labels-dropdown-flicker
fix labels dropdown flicker if no changes applied
2026-07-20 20:28:37 -04:00
Fred KISSIE 9cc3190e3a 🚧 WIP: batched status 2026-07-20 20:37:52 +01:00
dependabot[bot] 72f179578b Bump axios from 1.16.1 to 1.18.0
Bumps [axios](https://github.com/axios/axios) from 1.16.1 to 1.18.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.16.1...v1.18.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.18.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-20 19:06:46 +00:00
Fred KISSIE 4c873e7c48 Merge branch 'dev' into refactor/batch-status-requests 2026-07-20 18:38:00 +01:00
Fred KISSIE 56fcb80b23 💄 fix labels dropdown flicker if no changes applied 2026-07-20 17:36:45 +01:00
Owen Schwartz 41c68148a9 Merge pull request #3467 from fosrl/dev
1.21.0
2026-07-20 11:46:48 -04:00
Owen Schwartz 5d38059b95 Merge pull request #3466 from fosrl/api-improvements
api improvements
2026-07-20 11:44:25 -04:00
Owen df0198df9c Update internal -> private 2026-07-20 11:44:07 -04:00
Owen 26713b53f6 Fix #3462 2026-07-20 11:44:07 -04:00
Owen 2bc6b28978 Unify put vs post 2026-07-20 11:44:07 -04:00
Owen f1ed4da8a4 Move to legacy 2026-07-20 11:44:07 -04:00
Owen adeefb9dbd Add missing endpoints to api 2026-07-20 11:44:05 -04:00
Owen bf1cc705a5 Update tags 2026-07-20 11:43:10 -04:00
Owen 0b82dae01e More route cleanup 2026-07-20 11:43:09 -04:00
Owen 75afe6ece2 Order more api routes 2026-07-20 11:43:09 -04:00
Owen 70e2fe1e4e Just add ordering 2026-07-20 11:43:06 -04:00
Owen 17e03457e1 Recategorize 2026-07-20 11:42:37 -04:00
Owen 4d8cb7e231 Add aliaes for public/private resources 2026-07-20 11:42:35 -04:00
Owen 9561d23f1e Resovle endcoding issue 2026-07-19 14:41:07 -04:00
Shubham Singh 0275f44056 Fix Punycode domain validation 2026-07-19 13:45:44 +05:30
Owen a2e1c7b751 Go to domains if no domains 2026-07-18 16:30:29 -04:00
Owen 9e2ec72ced Fix missing resource id in cache 2026-07-18 16:15:30 -04:00
Owen 02fe1f3abd Claify that this is only for the cloud 2026-07-18 11:53:56 -04:00
Milo Schwartz c30fe5b574 Merge pull request #3457 from fosrl/dev
1.21.0
2026-07-17 17:17:35 -04:00
miloschwartz a0b9ea76a3 add badger to migration 2026-07-17 16:22:15 -04:00
miloschwartz eb3a3eac98 add 1.21 migrations 2026-07-17 15:58:13 -04:00
Owen 02d2e09709 Merge branch 'local-connection' into dev 2026-07-17 14:07:30 -04:00
miloschwartz 6e8283957c update remote node routes to support access token updates 2026-07-17 14:06:35 -04:00
miloschwartz bb9b94a983 add persistent session and users to access tokens 2026-07-17 13:39:09 -04:00
Owen 36f807ddbc Merge branch 'dev' into local-connection 2026-07-17 11:15:51 -04:00
miloschwartz cb31546c6b fix spelling in installer 2026-07-17 10:15:11 -04:00
miloschwartz 2d48adc9f9 use proxy for logout on org auth page session expire 2026-07-16 22:06:59 -04:00
Owen Schwartz 113b7a0b84 Merge pull request #3453 from fosrl/dev
1.20.0-s.3
2026-07-16 21:39:00 -04:00
Owen Schwartz ca279ee3e0 Merge pull request #3452 from fosrl/crowdin_dev
New Crowdin updates
2026-07-16 21:37:08 -04:00
Owen Schwartz a5808200f0 New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-07-16 21:34:54 -04:00
Owen Schwartz aa9de4d2ac New translations en-us.json (Chinese Simplified)
[ci skip]
2026-07-16 21:34:52 -04:00
Owen Schwartz 50821e972d New translations en-us.json (Turkish)
[ci skip]
2026-07-16 21:34:50 -04:00
Owen Schwartz e5b9dfee66 New translations en-us.json (Russian)
[ci skip]
2026-07-16 21:34:48 -04:00
Owen Schwartz 8e09070bc1 New translations en-us.json (Portuguese)
[ci skip]
2026-07-16 21:34:46 -04:00
Owen Schwartz 3d9c413bbb New translations en-us.json (Polish)
[ci skip]
2026-07-16 21:34:45 -04:00
Owen Schwartz 86670c1d60 New translations en-us.json (Dutch)
[ci skip]
2026-07-16 21:34:43 -04:00
Owen Schwartz 5f2ee77a73 New translations en-us.json (Korean)
[ci skip]
2026-07-16 21:34:41 -04:00
Owen Schwartz 714e3a8fb1 New translations en-us.json (Italian)
[ci skip]
2026-07-16 21:34:40 -04:00
Owen Schwartz 672555f051 New translations en-us.json (German)
[ci skip]
2026-07-16 21:34:38 -04:00
Owen Schwartz 7853f2b096 New translations en-us.json (Danish)
[ci skip]
2026-07-16 21:34:36 -04:00
Owen Schwartz 61a7dda29a New translations en-us.json (Czech)
[ci skip]
2026-07-16 21:34:35 -04:00
Owen Schwartz f9bcb25eaa New translations en-us.json (Bulgarian)
[ci skip]
2026-07-16 21:34:33 -04:00
Owen Schwartz 6df804ab32 New translations en-us.json (Spanish)
[ci skip]
2026-07-16 21:34:31 -04:00
Owen Schwartz 9e50735800 New translations en-us.json (French)
[ci skip]
2026-07-16 21:34:29 -04:00
miloschwartz a30119f5e8 encode email in url param 2026-07-16 21:28:36 -04:00
miloschwartz 3c9f0946d2 add site provisioning keys to integration api 2026-07-16 17:56:31 -04:00
Owen 21861a6dcd Merge branch 'local-connection' of github.com:fosrl/pangolin into local-connection 2026-07-16 17:25:58 -04:00
Owen 6135f2b727 Local endpoints from the get config 2026-07-16 17:25:51 -04:00
Owen 3a616cc804 Add handlers 2026-07-16 17:25:51 -04:00
Owen d5eb67a8fc Send localEndpoints on sites to olm 2026-07-16 17:25:51 -04:00
Owen 3ac7ef23ae Save the local endpoints 2026-07-16 17:25:51 -04:00
Owen 08c5ec2be7 Add new column 2026-07-16 17:25:51 -04:00
Owen a88b79e066 Add timezone offset to fix bad display 2026-07-16 16:09:35 -04:00
Owen a48ef77ee5 Merge branch 'dev' of github.com:fosrl/pangolin into dev 2026-07-16 15:49:52 -04:00
Owen 55f3807491 Enforce uniqueness of aliases in blueprints 2026-07-16 15:10:38 -04:00
Owen dbfb8e83e8 Local endpoints from the get config 2026-07-16 14:43:45 -04:00
Owen 515afc14a5 Add handlers 2026-07-16 11:23:53 -04:00
Owen 903e8c0fa1 Send localEndpoints on sites to olm 2026-07-16 10:47:24 -04:00
Owen 7f9c760380 Save the local endpoints 2026-07-16 09:59:11 -04:00
Owen 530a1a5350 Add new column 2026-07-16 09:59:07 -04:00
miloschwartz 3fb36c1434 add missing translation 2026-07-15 22:03:28 -04:00
Owen Schwartz 5d20956a0e Merge pull request #3450 from fosrl/dev
1.20.0-s.2
2026-07-15 20:55:40 -04:00
Owen Schwartz 29f91e8276 Merge pull request #3449 from fosrl/crowdin_dev
New Crowdin updates
2026-07-15 20:54:40 -04:00
Owen Schwartz 4d9e38d022 New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-07-15 20:54:17 -04:00
Owen Schwartz d7d31b78f0 New translations en-us.json (Chinese Simplified)
[ci skip]
2026-07-15 20:54:15 -04:00
Owen Schwartz e8ed8fc7e9 New translations en-us.json (Turkish)
[ci skip]
2026-07-15 20:54:13 -04:00
Owen Schwartz 5ee406c92e New translations en-us.json (Russian)
[ci skip]
2026-07-15 20:54:12 -04:00
Owen Schwartz 89f4f4ba6f New translations en-us.json (Portuguese)
[ci skip]
2026-07-15 20:54:10 -04:00
Owen Schwartz 4e5fd2f2b0 New translations en-us.json (French)
[ci skip]
2026-07-15 20:54:08 -04:00
Owen Schwartz 22a5743817 New translations en-us.json (Polish)
[ci skip]
2026-07-15 20:54:06 -04:00
Owen Schwartz 40e05f903a New translations en-us.json (Dutch)
[ci skip]
2026-07-15 20:54:04 -04:00
Owen Schwartz 6c1e1bc0ca New translations en-us.json (Korean)
[ci skip]
2026-07-15 20:54:02 -04:00
Owen Schwartz cebbf9ca65 New translations en-us.json (Italian)
[ci skip]
2026-07-15 20:54:00 -04:00
Owen Schwartz e6646e5833 New translations en-us.json (German)
[ci skip]
2026-07-15 20:53:59 -04:00
Owen Schwartz 00f7510195 New translations en-us.json (Danish)
[ci skip]
2026-07-15 20:53:57 -04:00
Owen Schwartz 8d1f5e1096 New translations en-us.json (Czech)
[ci skip]
2026-07-15 20:53:55 -04:00
Owen Schwartz e497fcc3b9 New translations en-us.json (Bulgarian)
[ci skip]
2026-07-15 20:53:53 -04:00
Owen Schwartz a7010b2788 New translations en-us.json (Spanish)
[ci skip]
2026-07-15 20:53:51 -04:00
Owen abf58a70ee Merge branch 'provisioning-resources' into dev 2026-07-15 20:45:39 -04:00
Owen d4c602cf91 Update the counts when deleting resources 2026-07-15 17:22:12 -04:00
miloschwartz d1b0bbb6d9 update translations 2026-07-15 16:05:53 -04:00
miloschwartz ae0be3a4bc add resources overview column to pending sites table 2026-07-12 17:53:57 -04:00
miloschwartz a8f3f71021 add reject endpoint 2026-07-12 17:48:48 -04:00
miloschwartz 933ca71c16 add approve site endpoint 2026-07-12 17:16:08 -04:00
miloschwartz 591cb9cdc1 dont use strict query params in list alises 2026-07-10 18:05:16 -04:00
miloschwartz 34b18bdb53 only show approved resources in launcher 2026-07-10 17:24:10 -04:00
miloschwartz 7d475f5e91 filter out pending resources 2026-07-10 17:18:33 -04:00
miloschwartz 39c35fa539 reorganize components nad hooks for consistency 2026-07-10 17:05:45 -04:00
Milo Schwartz bada1fdb97 Merge pull request #3394 from fosrl/feat/remember-last-idp-on-smart-login-form
feat: Remember last used idp in login forms
2026-07-10 16:34:22 -04:00
miloschwartz d9303f87c8 set last used cookie in smart login lookup 2026-07-10 16:34:02 -04:00
Owen e1bc0b7efd Make sure the enabled gets set to false 2026-07-10 15:36:05 -04:00
Owen 5ef068c8dc Set the resource from the site 2026-07-10 15:31:37 -04:00
miloschwartz 14680df160 suse tiermatrix in server component susbcribed check 2026-07-10 15:23:09 -04:00
Owen 94c01a23a9 Merge branch 'private-resource-enable' into provisioning-resources 2026-07-10 15:17:06 -04:00
Owen 609fb357bb Support enable in the blueprints 2026-07-10 15:16:50 -04:00
Owen cf9a17cc2e Add status filter 2026-07-10 15:04:28 -04:00
Owen 538b57941c Makes sure patch works
z#
2026-07-10 11:32:27 -04:00
Owen f4bee6406a Support partial updates 2026-07-10 10:11:47 -04:00
Owen 0b2693a317 Add toggle to the ui for enabled 2026-07-10 09:59:11 -04:00
Fred KISSIE 1580b7abff 🚧 wip: batch status histories 2026-07-10 07:02:22 +02:00
Owen 3be2d928f6 Filter out disabled 2026-07-09 21:42:59 -04:00
Milo Schwartz e5398d441e Merge pull request #3425 from fosrl/dev
Dev
2026-07-09 21:33:17 -04:00
miloschwartz d38f9ac2bb update readme 2026-07-09 21:32:35 -04:00
Owen 8d018fe47d Clean up 2026-07-09 21:28:48 -04:00
Fred KISSIE 34d5c9535d ♻️ delete last used IDP coookie when logging in with email/password on smart login form 2026-07-10 03:22:28 +02:00
Fred KISSIE dc60ef712f Merge branch 'dev' into feat/remember-last-idp-on-smart-login-form 2026-07-10 02:38:38 +02:00
Owen dc7eb630c3 Add missing action 2026-07-09 11:16:15 -04:00
Milo Schwartz d4138e2141 Merge pull request #3421 from fosrl/dev
1.20.0
2026-07-09 10:40:35 -04:00
Owen Schwartz bbcd352817 Merge pull request #3420 from fosrl/crowdin_dev
New Crowdin updates
2026-07-09 10:35:21 -04:00
Owen Schwartz 21d9199978 New translations en-us.json (French)
[ci skip]
2026-07-09 10:34:31 -04:00
Owen Schwartz 3e7b9d7bff New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-07-09 10:34:29 -04:00
Owen Schwartz 54acdbe282 New translations en-us.json (Chinese Simplified)
[ci skip]
2026-07-09 10:34:27 -04:00
Owen Schwartz 11bbd028fc New translations en-us.json (Turkish)
[ci skip]
2026-07-09 10:34:24 -04:00
Owen Schwartz 7ae99731f4 New translations en-us.json (Russian)
[ci skip]
2026-07-09 10:34:22 -04:00
Owen Schwartz 17b4fd9a44 New translations en-us.json (Portuguese)
[ci skip]
2026-07-09 10:34:20 -04:00
Owen Schwartz caa398ae54 New translations en-us.json (Polish)
[ci skip]
2026-07-09 10:34:18 -04:00
Owen Schwartz 035e324e5a New translations en-us.json (Dutch)
[ci skip]
2026-07-09 10:34:16 -04:00
Owen Schwartz 272a5737b0 New translations en-us.json (Korean)
[ci skip]
2026-07-09 10:34:14 -04:00
Owen Schwartz be50fd4586 New translations en-us.json (Italian)
[ci skip]
2026-07-09 10:34:12 -04:00
Owen Schwartz 8301511efe New translations en-us.json (German)
[ci skip]
2026-07-09 10:34:10 -04:00
Owen Schwartz c527ce6562 New translations en-us.json (Danish)
[ci skip]
2026-07-09 10:34:08 -04:00
Owen Schwartz 80bee900aa New translations en-us.json (Czech)
[ci skip]
2026-07-09 10:34:06 -04:00
Owen Schwartz 54209c2390 New translations en-us.json (Bulgarian)
[ci skip]
2026-07-09 10:34:04 -04:00
Owen Schwartz 3b0ee8d9d4 New translations en-us.json (Spanish)
[ci skip]
2026-07-09 10:34:02 -04:00
miloschwartz 7a0a877dc0 fix create private resource ports default to blocked 2026-07-09 10:28:06 -04:00
Owen b6ed762724 Sort before inserting 2026-07-09 10:18:41 -04:00
Owen Schwartz d1d58cf455 Merge pull request #3418 from fosrl/crowdin_dev
New Crowdin updates
2026-07-09 10:00:30 -04:00
Owen Schwartz e22f30c694 New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-07-09 09:59:33 -04:00
Owen Schwartz d71c112959 New translations en-us.json (Turkish)
[ci skip]
2026-07-09 09:59:30 -04:00
Owen Schwartz 199e960f6d New translations en-us.json (Portuguese)
[ci skip]
2026-07-09 09:59:26 -04:00
Owen Schwartz 843b9a0b16 New translations en-us.json (Dutch)
[ci skip]
2026-07-09 09:59:23 -04:00
Owen Schwartz b9aedc65fe New translations en-us.json (Italian)
[ci skip]
2026-07-09 09:59:20 -04:00
Owen Schwartz 11f5cfd4f8 New translations en-us.json (German)
[ci skip]
2026-07-09 09:59:18 -04:00
Owen Schwartz 2ff2e32e9c New translations en-us.json (Danish)
[ci skip]
2026-07-09 09:59:16 -04:00
Owen Schwartz facb1e4cdf New translations en-us.json (Spanish)
[ci skip]
2026-07-09 09:59:11 -04:00
Owen 6d129c0738 Fix #3099 2026-07-08 21:44:20 -04:00
Owen 67a3d226f5 Add session creation 2026-07-08 21:17:40 -04:00
Owen 19a3773880 Update ironrdp
Ref #3272
2026-07-08 16:23:53 -04:00
Milo Schwartz 09d5d9082e Merge pull request #3413 from fosrl/dev
1.20.0
2026-07-08 15:30:03 -04:00
Aditya kumar singh bb5547a157 fix: redirect to /auth/initial-setup after hitting auth rate limit (#3408) 2026-07-08 23:33:57 +05:30
Owen Schwartz 8eab3b1ee2 Merge pull request #3409 from fosrl/crowdin_dev
New Crowdin updates
2026-07-08 14:00:41 -04:00
Owen Schwartz 30b46ebb6e New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-07-08 13:59:57 -04:00
Owen Schwartz 91d5f89ab9 New translations en-us.json (Chinese Simplified)
[ci skip]
2026-07-08 13:59:55 -04:00
Owen Schwartz 703050f949 New translations en-us.json (Turkish)
[ci skip]
2026-07-08 13:59:53 -04:00
Owen Schwartz 97af17a993 New translations en-us.json (Russian)
[ci skip]
2026-07-08 13:59:51 -04:00
Owen Schwartz 09a8c457ac New translations en-us.json (Portuguese)
[ci skip]
2026-07-08 13:59:49 -04:00
Owen Schwartz 80317aca83 New translations en-us.json (Polish)
[ci skip]
2026-07-08 13:59:47 -04:00
Owen Schwartz bd10519598 New translations en-us.json (Dutch)
[ci skip]
2026-07-08 13:59:45 -04:00
Owen Schwartz 15c7c47b3c New translations en-us.json (Korean)
[ci skip]
2026-07-08 13:59:43 -04:00
Owen Schwartz 0a7e8c3cc2 New translations en-us.json (Italian)
[ci skip]
2026-07-08 13:59:42 -04:00
Owen Schwartz fc2668fca8 New translations en-us.json (German)
[ci skip]
2026-07-08 13:59:40 -04:00
Owen Schwartz 8f2c20a2bc New translations en-us.json (Danish)
[ci skip]
2026-07-08 13:59:38 -04:00
Owen Schwartz 462ae7ee8c New translations en-us.json (Czech)
[ci skip]
2026-07-08 13:59:36 -04:00
Owen Schwartz 808abd2038 New translations en-us.json (Bulgarian)
[ci skip]
2026-07-08 13:59:34 -04:00
Owen Schwartz 3de25e51ff New translations en-us.json (Spanish)
[ci skip]
2026-07-08 13:59:32 -04:00
Owen Schwartz ef25a9dbe0 New translations en-us.json (French)
[ci skip]
2026-07-08 13:59:30 -04:00
miloschwartz e21c3afb13 fix restart site styling 2026-07-08 12:32:29 -04:00
Milo Schwartz cb7962c25d Merge pull request #3200 from Adityakk9031/#3001
fix: redirect lost after Pangolin auth session timeout (#3001)
2026-07-08 12:30:39 -04:00
miloschwartz f667ee4feb fix api reference 2026-07-08 12:29:23 -04:00
miloschwartz 810119e4ee Merge branch 'dev' into #3001 2026-07-08 12:10:13 -04:00
miloschwartz d082de3d88 command palette tweaks 2026-07-07 21:55:15 -04:00
Milo Schwartz 1e7863ce4f Merge pull request #3342 from Fredkiss3/feat/geoip-country-is-not-rule
feat: add country `is not` rule in resource policies
2026-07-07 21:18:43 -04:00
Milo Schwartz a942871f3d Merge pull request #3263 from Fredkiss3/feat/command-bar
feat: Command Palette for searching accross the dashboard
2026-07-07 21:17:39 -04:00
Fred KISSIE 2fe85ebda2 ♻️ set last IDP to null if login with email + password 2026-07-08 03:11:12 +02:00
Milo Schwartz da1205d38e Merge pull request #3407 from fosrl/fix/non-semver-version-error
fix: invalid version causing crashes in the frontend
2026-07-07 21:09:05 -04:00
Fred KISSIE 92775a51ca 🐛 fix invalid semver version causing crashes in frontend 2026-07-08 01:44:28 +02:00
Fred KISSIE ea60aa701e 🐛 fix sitetable page crashing because of invalid version 2026-07-08 01:39:52 +02:00
Fred KISSIE b1f7ee02b0 🐛 fix imports 2026-07-08 00:58:03 +02:00
Fred KISSIE affa3bbb6a Merge branch 'dev' into feat/command-bar 2026-07-08 00:33:28 +02:00
miloschwartz dbb1e7b274 dont show non sso resoruces 2026-07-07 18:10:12 -04:00
miloschwartz b2d1a66279 bump version 2026-07-07 17:08:49 -04:00
miloschwartz 202466792c fix search icon alignment 2026-07-07 16:34:37 -04:00
miloschwartz 4dcf684868 add 1.20.0 migration 2026-07-07 16:04:41 -04:00
miloschwartz 2b8f50af6f add missing api key perms to permissions selector 2026-07-07 15:23:41 -04:00
Owen a8c255ef71 Fix type issue 2026-07-07 15:21:23 -04:00
miloschwartz d9608bd408 improve role required feedback on user 2026-07-07 15:17:29 -04:00
Owen be193731c1 Reorder labels columns 2026-07-07 15:15:00 -04:00
Owen 0c3edc7560 Further remove labels 2026-07-07 15:12:14 -04:00
miloschwartz 813a3f07dc Merge branch 'dev' into private-resource-page 2026-07-07 15:07:35 -04:00
Owen 417438c209 Remove feature flags from labels 2026-07-07 15:04:41 -04:00
miloschwartz beaa5735ce move cert to info box and add network access to create wizard 2026-07-07 14:51:15 -04:00
Owen Schwartz 5cb316f4e9 Merge pull request #3404 from fosrl/dev
Add ? to support undefined
2026-07-07 10:59:56 -04:00
Owen 83de8576bf Add ? to support undefined 2026-07-07 10:59:01 -04:00
Owen Schwartz 6b6c9cf4d8 Merge pull request #3403 from fosrl/dev
Move jit mode to a config param
2026-07-07 10:41:59 -04:00
Owen e9d424eb80 Move jit mode to a config param
Ref #3262
2026-07-07 10:41:12 -04:00
Owen Schwartz 05617c63c0 Merge pull request #3402 from fosrl/dev
1.19.4-s.5
2026-07-07 10:21:57 -04:00
Owen 8cceed91cf Make feilds optional 2026-07-07 09:02:25 -04:00
Owen 633159fb77 Email whitelist should respect policies 2026-07-07 08:37:20 -04:00
miloschwartz 4c8bd4db0a includen on sso resources in launcher 2026-07-06 22:00:35 -04:00
miloschwartz d742300e82 add mising tooltips to collapsed sidebar 2026-07-06 21:52:22 -04:00
miloschwartz a521db91a2 migrate private resources to page 2026-07-06 21:48:29 -04:00
Owen d4549f1c33 log error 2026-07-06 14:44:30 -04:00
Owen 71da22328c Quiet logs 2026-07-06 14:44:30 -04:00
miloschwartz 0b5b732a48 Merge branch 'resource-launcher-compact' into dev 2026-07-06 14:19:58 -04:00
miloschwartz a82bae8f93 add resource info to side panel 2026-07-06 12:45:07 -04:00
Owen e59176149b Disable jit
Fixes #3262
2026-07-06 11:01:40 -04:00
Owen 2c3151da9b Fix #3374 2026-07-03 17:36:06 -04:00
Owen f60b8795ad Fix #3383 2026-07-03 17:26:49 -04:00
Owen 4054976388 Fix #3395 2026-07-03 17:15:48 -04:00
Fred KISSIE 11c30b8b27 Merge branch 'dev' into feat/geoip-country-is-not-rule 2026-07-03 22:50:56 +02:00
Fred KISSIE 3d13e9105c 🏷️ fix types 2026-07-03 22:33:58 +02:00
Fred KISSIE 289be30e6b show last used login idp in smart login form 2026-07-03 22:33:51 +02:00
Owen Schwartz d4c52bbf2f Merge pull request #3396 from fosrl/dev
1.19.4-s.5
2026-07-03 16:15:18 -04:00
Owen 390c822bb4 Fix issue with overlapping site resources not sending 2026-07-03 16:13:50 -04:00
Fred KISSIE a74c0c227c 🌐 text 2026-07-03 21:05:53 +02:00
Fred KISSIE 05bf77da29 ♻️ remove cache on verifySession calls as it's already wrapped in cache 2026-07-03 21:05:47 +02:00
Fred KISSIE bb4deb1ae9 remember last used idp in dashboard login form 2026-07-03 21:05:16 +02:00
Owen 2bfc1901a6 Dont check the subnet because we dont use it 2026-07-03 14:38:01 -04:00
Owen 5da186b528 Quiet warning messages 2026-07-03 11:58:00 -04:00
Owen 600a96c13b Update rate limit to 10 2026-07-03 11:52:59 -04:00
Owen e4e0da3723 Add not exists check 2026-07-03 11:24:53 -04:00
miloschwartz 4087d7fb6b remove text from refresh button 2026-07-03 11:18:06 -04:00
miloschwartz 9edb86fd73 add invalidate launcher cache on refresh 2026-07-03 10:30:41 -04:00
Owen Schwartz 87f50bf0cc Merge pull request #3389 from fosrl/dev
1.19.4-s.3
2026-07-03 10:29:15 -04:00
Owen 440ebfe08e Clarify error messages 2026-07-03 10:26:13 -04:00
Owen b399d2a291 Add some retry and database confict mitigation 2026-07-03 10:23:32 -04:00
Owen Schwartz 2c66da1b19 Merge pull request #3386 from v1rusnl/main
Upgrade Traefik image to version 3.7
2026-07-03 10:18:44 -04:00
miloschwartz 811119a9a6 reduce filter dropdown page size 2026-07-03 10:13:58 -04:00
miloschwartz b53f80d317 use column for default view 2026-07-03 09:59:49 -04:00
Owen 1b1fba60f1 Make sure to retry rebuilds 2026-07-03 09:02:08 -04:00
v1rusnl ab19955502 Upgrade Traefik image to version 3.7 2026-07-03 08:22:13 +02:00
v1rusnl 1db9dcec81 Update Traefik image version to v3.7 2026-07-03 08:21:12 +02:00
miloschwartz a89509c8bd add more caching 2026-07-02 23:32:16 -04:00
miloschwartz f0546eb622 add save default view 2026-07-02 22:27:51 -04:00
miloschwartz 12f9ac94fd allow grouping when filter applied 2026-07-02 22:07:30 -04:00
miloschwartz 1717a99cee add compact mode for large orgs 2026-07-02 21:53:52 -04:00
Owen b93d26f09f Fix reading from replicas 2026-07-02 21:46:53 -04:00
Owen fc54ad49b5 Fix trial showing 2026-07-02 21:46:44 -04:00
Owen f87e136f6b Unique subnets for exit nodes 2026-07-02 20:54:59 -04:00
Owen 1bf3d2cdd6 Add back the sync with semver 2026-07-02 12:10:20 -04:00
Owen 5fc5a3ebca Adjust spacing 2026-07-02 11:49:49 -04:00
Owen Schwartz 49c2d3163e Merge pull request #3381 from fosrl/dev
dev
2026-07-02 10:56:39 -04:00
Owen e40f325703 Add new limits to the billing page 2026-07-02 10:55:46 -04:00
Owen 8f377a4fb2 Dont run on cloud 2026-07-01 22:07:33 -04:00
Owen Schwartz 45b9e13a13 Merge pull request #3378 from fosrl/dev
1.19.4-s.1
2026-07-01 21:48:01 -04:00
Owen Schwartz 3699f8f9cb Merge pull request #3380 from fosrl/resource-launcher
improve pagination
2026-07-01 21:47:14 -04:00
Owen 5a2388a1e6 Fix imports 2026-07-01 21:43:51 -04:00
Owen 86e6ebc8af Comment out sync again for now 2026-07-01 21:40:08 -04:00
miloschwartz 005f050a81 improve pagination 2026-07-01 21:36:23 -04:00
Owen Schwartz c82678852e Merge pull request #3377 from fosrl/crowdin_dev
New Crowdin updates
2026-07-01 21:30:26 -04:00
Owen Schwartz 2e3ab10f5e New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-07-01 21:28:16 -04:00
Owen Schwartz 4985ed02d3 New translations en-us.json (Chinese Simplified)
[ci skip]
2026-07-01 21:28:14 -04:00
Owen Schwartz a2fea7f714 New translations en-us.json (Turkish)
[ci skip]
2026-07-01 21:28:12 -04:00
Owen Schwartz ddba2aff21 New translations en-us.json (Russian)
[ci skip]
2026-07-01 21:28:11 -04:00
Owen Schwartz da9e668fb8 New translations en-us.json (Portuguese)
[ci skip]
2026-07-01 21:28:09 -04:00
Owen Schwartz 9c2d14d8c6 New translations en-us.json (Polish)
[ci skip]
2026-07-01 21:28:07 -04:00
Owen Schwartz c383e74df4 New translations en-us.json (Dutch)
[ci skip]
2026-07-01 21:28:05 -04:00
Owen Schwartz 40101f8bb0 New translations en-us.json (Korean)
[ci skip]
2026-07-01 21:28:03 -04:00
Owen Schwartz b20ed9efa9 New translations en-us.json (Italian)
[ci skip]
2026-07-01 21:28:02 -04:00
Owen Schwartz 56266e3b62 New translations en-us.json (German)
[ci skip]
2026-07-01 21:28:00 -04:00
Owen Schwartz 47eff8c948 New translations en-us.json (Danish)
[ci skip]
2026-07-01 21:27:58 -04:00
Owen Schwartz 15f36096ef New translations en-us.json (Czech)
[ci skip]
2026-07-01 21:27:57 -04:00
Owen Schwartz 064252586d New translations en-us.json (Bulgarian)
[ci skip]
2026-07-01 21:27:55 -04:00
Owen Schwartz 6181d46a1e New translations en-us.json (Spanish)
[ci skip]
2026-07-01 21:27:53 -04:00
Owen Schwartz 9747731668 New translations en-us.json (French)
[ci skip]
2026-07-01 21:27:51 -04:00
Owen Schwartz 2a478eef6f Merge pull request #3375 from fosrl/resource-launcher
Resource launcher
2026-07-01 21:19:19 -04:00
Owen 4ab101f8a9 Fix lock 2026-07-01 21:13:40 -04:00
miloschwartz e35878ee55 add caching 2026-07-01 19:59:38 -04:00
Owen 807613f28c Move rate limit up 2026-07-01 17:30:07 -04:00
miloschwartz 663244fa3a support labels in list alises 2026-07-01 16:17:27 -04:00
Owen bcc128aeb6 Fix ping function 2026-07-01 15:47:24 -04:00
Owen ba33cb9895 Increment config version for this 2026-07-01 15:44:08 -04:00
Owen 69e7fedcfc Add the remote exit node info 2026-07-01 15:28:26 -04:00
miloschwartz 023110b341 update en-us 2026-07-01 15:18:49 -04:00
miloschwartz 7fb95e1726 restore script 2026-07-01 15:15:43 -04:00
miloschwartz db0a7cc1ce fix toolbar responsiveness and disable side panel 2026-07-01 15:14:29 -04:00
Owen 61fc2e5ea7 Fix restartSite import 2026-07-01 14:48:36 -04:00
miloschwartz 0871a211ec open side panel on click 2026-07-01 14:30:43 -04:00
miloschwartz 5a1d5cb66e redirect to settings if org admin 2026-07-01 14:06:33 -04:00
miloschwartz 5a7ca5b542 add refresh button 2026-07-01 12:38:45 -04:00
miloschwartz 87e1a509ce show sites and labels for non admins 2026-07-01 12:17:36 -04:00
miloschwartz 75f481bc3d improve search bar debounce behavior 2026-07-01 11:53:11 -04:00
miloschwartz 97cdb2eb5a use proper label filter selector 2026-07-01 11:46:20 -04:00
miloschwartz 297fd2caf3 standardize permissions in api 2026-07-01 11:26:14 -04:00
miloschwartz 22dd4220fe fix ordering of buttons 2026-07-01 11:09:26 -04:00
Owen 108cb6216c Add migration to fix policy delete issues
Ref #3257
2026-07-01 10:39:41 -04:00
Owen e3ef592778 Adjust language 2026-07-01 10:39:41 -04:00
miloschwartz 3c37e10638 add mobile overflow menu 2026-07-01 10:11:17 -04:00
miloschwartz 561f75b6b1 improve responsiveness 2026-07-01 10:03:06 -04:00
Owen Schwartz e98bcb83ac Merge pull request #3371 from jaisinha77777/fix/oss-listexitnodes-siteid-signature
Fix OSS build break: add siteId param to OSS listExitNodes
2026-07-01 08:44:12 -04:00
Owen Schwartz 80284863bb Merge pull request #3369 from jaisinha77777/fix/private-resources-delete-error-toast
Fix broken toast on private resource delete failure
2026-07-01 08:43:50 -04:00
miloschwartz bc759c5c9e improve mobile responsiveness 2026-06-30 22:11:25 -04:00
miloschwartz f4854a3a74 include exit node endpoint in tcp/udp resources 2026-06-30 21:54:27 -04:00
miloschwartz 376dd465b3 show no site category 2026-06-30 21:49:08 -04:00
jaisinha77777 296439fd67 Fix OSS build break: add siteId param to OSS listExitNodes
listExitNodes is resolved via the #dynamic path alias, which maps to
server/lib in the OSS build and server/private/lib in enterprise/saas.
Commit 9c18936b added a 4th siteId argument to the shared caller
(handleNewtPingRequestMessage) and to the enterprise implementation, but
not to the OSS one, so under the OSS tsconfig the call fails:

  handleNewtPingRequestMessage.ts: error TS2554: Expected 1-3 arguments,
  but got 4.

This breaks 'npx tsc --noEmit' for the OSS build (the CI 'Test with tsc'
step runs it after set:oss). Add siteId?: number to the OSS signature
for parity. It is unused in OSS since that build has no remote exit
nodes to label-filter; accepting it keeps the two #dynamic
implementations interface-compatible.
2026-07-01 07:17:03 +05:30
jaisinha77777 31f675f38c Fix broken toast on private resource delete failure
The delete-error handler in PrivateResourcesTable referenced two i18n
keys that do not exist in the message catalog:

- console.error used "resourceErrorDelete" (the catalog key is
  "resourceErrorDelte"), logging a raw key instead of the message.
- the toast description passed "v" to formatAxiosError, so a failed
  delete showed the user a bogus fallback title.

Point both at the existing "resourceErrorDelte" key, matching the
delete handlers in PublicResourcesTable and ResourcePoliciesTable. No
catalog/translation changes, so nothing changes for Crowdin.
2026-07-01 07:04:54 +05:30
miloschwartz 9f68be2a9b add empty state 2026-06-30 21:18:46 -04:00
miloschwartz fed4ec42c4 add server side fetching 2026-06-30 21:15:03 -04:00
miloschwartz f0efa4203b basic functionality 2026-06-30 21:03:19 -04:00
Owen cfbbdedaf5 rework ui 2026-06-30 17:44:35 -04:00
Owen 686789ee4c Properly translate 2026-06-30 15:37:42 -04:00
Owen 3fda190ff6 Merge branch 'backhaul' into dev 2026-06-30 15:21:11 -04:00
Owen 0033f40f4d Fix typo preventing subscription cancelation 2026-06-30 15:06:35 -04:00
Owen af95052706 Update schema for tracking valid domains 2026-06-30 13:53:44 -04:00
Owen 9bb2d6cdc8 Prompt for the username on vnc 2026-06-30 12:16:58 -04:00
Owen 29563a13a4 Add indexes on the niceId and orgId 2026-06-30 09:49:53 -04:00
Owen b41c1f5b27 Add restart button 2026-06-29 21:10:49 -04:00
Owen e5652cdb8a Dont enable admin routes 2026-06-29 20:45:38 -04:00
Owen 7c2ea153c5 Use regional cache for rate limiting 2026-06-29 18:33:03 -04:00
Owen ccabddc225 Add logging for access for new public resources 2026-06-29 18:05:29 -04:00
Owen 42d98fa83b Comment back in the sync command 2026-06-29 16:34:10 -04:00
Owen 2f2b7f43c1 Add usage tracking to blueprints 2026-06-29 16:13:12 -04:00
Owen 528bbeca26 Implement usage tracking on resources, clients 2026-06-29 15:39:30 -04:00
Owen d60c15b0ae Fix typo 2026-06-29 15:24:16 -04:00
Owen ff89a64453 Rename to limit id 2026-06-29 15:22:35 -04:00
Owen 4718c489d3 Add concurrency guard calculateUserClientsForOrgs 2026-06-29 15:02:41 -04:00
Owen d5d99a4804 Add org rebuild rate limit 2026-06-29 14:59:05 -04:00
Fred KISSIE 8d29602929 🏷️ fix types 2026-06-29 18:06:03 +02:00
Owen faee9e6330 Merge branch 'main' into dev 2026-06-29 11:29:12 -04:00
miloschwartz 31725eb3cc display resource type in info card 2026-06-29 10:45:38 -04:00
miloschwartz 04d4e298e8 fix spacing on endpoint field on site 2026-06-29 10:42:25 -04:00
Fred KISSIE 6c2a8bf6de 💄 Show the selected country in policy access rules country is not rule match 2026-06-27 02:12:09 +02:00
Fred KISSIE 697be01411 Merge branch 'dev' into feat/geoip-country-is-not-rule 2026-06-27 02:05:02 +02:00
Fred KISSIE 1bd6f240cc ♻️ add Country is not rule to verifySession 2026-06-26 23:18:46 +02:00
miloschwartz 7506c0420d properly pass org policy error message in olm register 2026-06-26 17:11:32 -04:00
Owen Schwartz 5572822c4a Merge pull request #3351 from fosrl/copilot/fix-rest-ruleid-changing
fix: preserve rule IDs when saving policy rules via the GUI
2026-06-26 15:05:31 -04:00
copilot-swe-agent[bot] 5428bf4ed0 fix: preserve rule IDs when saving policy rules through the GUI
The `setResourcePolicyRules` endpoint was deleting all existing rules and
re-inserting them on every save, causing all ruleIDs to change.

Backend: Accept an optional `ruleId` per rule in the request body and
perform an upsert — update existing rules (matched by ruleId), insert
new ones (no ruleId), and delete only rules absent from the incoming list.

Frontend: Include `ruleId` in the rules payload for existing (non-new)
rules so the backend can match and preserve them.
2026-06-26 14:37:34 +00:00
copilot-swe-agent[bot] 9a89579e08 Initial plan 2026-06-26 14:33:35 +00:00
Owen Schwartz 784588cebc Merge pull request #3350 from fosrl/dev
Make sure the rebuild actually executes
2026-06-26 09:28:12 -04:00
Owen Schwartz 7590e8d8a1 Merge pull request #3345 from fosrl/dev
Show utility subnet on org
2026-06-25 13:05:32 -07:00
Fred KISSIE 278375f7be 🚧 More country is not rule 2026-06-25 21:26:13 +02:00
Fred KISSIE 65c383520b 💄 adjust match type column width 2026-06-25 20:49:55 +02:00
Fred KISSIE e54bd25516 🚧 add country is not rule 2026-06-25 20:28:57 +02:00
Fred KISSIE 8f0e17774f 💄 Update command bar 2026-06-22 21:22:55 +02:00
Fred KISSIE 2ab5540085 ♻️ add command palette trigger on the header 2026-06-19 18:08:36 +02:00
Fred KISSIE 4e7328a1cc ♻️ refactor private resource page 2026-06-16 22:29:03 +02:00
Fred KISSIE 6380079239 Command palette with search & actions 2026-06-16 22:28:31 +02:00
Fred KISSIE b6aba13d3a ♻️ rename query, add query 2026-06-16 22:28:04 +02:00
Fred KISSIE c0a9db92ef 💄 update command bar UI 2026-06-16 19:28:22 +02:00
Fred KISSIE 3bc5ab2136 🚧 wip 2026-06-13 00:48:32 +02:00
Fred KISSIE ce77268c82 🚧 wip: block top position of command palette 2026-06-13 00:47:36 +02:00
Fred KISSIE abc0a41d9e ♻️ reorganize command bar items 2026-06-13 00:27:39 +02:00
Fred KISSIE a68b57067c 🚧 wip: Use command bar nav items 2026-06-12 23:51:05 +02:00
Fred KISSIE 444d293a29 🚧 WIP: copy command bar from existing PR 2026-06-11 23:46:11 +02:00
Aditya kumar singh 35d16ac683 fix: redirect lost after Pangolin auth session timeout (#3001) 2026-06-01 15:53:10 +05:30
766 changed files with 86926 additions and 16210 deletions
+31
View File
@@ -0,0 +1,31 @@
---
name: crud-endpoints
description: Use whenever asked to add, create, or scaffold a CRUD endpoint, router, or entity in this repo's server (create/list/get/update/delete handlers, new `server/routers/<entity>/` or `server/private/routers/<entity>/` folder). Points to the established file layout, middleware, ActionsEnum, and route-registration conventions before writing any code.
---
Before writing any router/handler/middleware code for a new entity, read
`docs/crud-endpoints.md` in full. It documents, with real examples from
`server/routers/aiProvider/` (public) and `server/private/routers/alertRule/`
(enterprise-only), how this repo structures CRUD endpoints:
- Directory/file layout per entity (`index.ts`, `types.ts`, `validation.ts`,
one file per operation).
- The standard handler anatomy (zod parsing, OpenAPI registry, response
envelope, error handling).
- Where access-control middleware (`verify<Entity>Access`) lives and when
it's needed vs. plain `verifyOrgAccess`.
- How to wire up `ActionsEnum` entries, `verifyUserHasAction`, and
`logActionAudit`.
- Which of the four router files (`server/routers/external.ts`,
`server/routers/internal.ts`, `server/private/routers/external.ts`,
`server/private/routers/internal.ts`) to register routes in, and the
middleware chain template per HTTP verb.
- The repo's non-standard verb convention: **`PUT` = create, `POST` =
update** (backwards from typical REST) — don't "fix" this to standard
REST verbs, match the existing convention.
- The `#dynamic` import alias, for the rare case of a hook needing different
implementations in OSS vs. enterprise builds.
Follow that doc's checklist (§8) step by step rather than improvising a
structure. If the doc and the actual code in `aiProvider`/`alertRule` ever
disagree, trust the code and flag the doc as stale.
+5
View File
@@ -0,0 +1,5 @@
---
alwaysApply: true
---
Don't write or edit migrations in `server/setup` unless specificall instructed to do so.
+9 -9
View File
@@ -62,7 +62,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Monitor storage space
run: |
@@ -77,7 +77,7 @@ jobs:
fi
- name: Log in to Docker Hub
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }}
@@ -134,7 +134,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Monitor storage space
run: |
@@ -149,7 +149,7 @@ jobs:
fi
- name: Log in to Docker Hub
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }}
@@ -201,10 +201,10 @@ jobs:
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Log in to Docker Hub
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }}
@@ -256,7 +256,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Extract tag name
id: get-tag
@@ -264,7 +264,7 @@ jobs:
shell: bash
- name: Install Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: 1.25
@@ -407,7 +407,7 @@ jobs:
shell: bash
- name: Login to GitHub Container Registry (for cosign)
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
+2 -2
View File
@@ -21,10 +21,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
with:
days-before-stale: 14
days-before-close: 14
+4 -4
View File
@@ -14,10 +14,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
@@ -62,7 +62,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Build Docker image sqlite
run: make dev-build-sqlite
@@ -71,7 +71,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Build Docker image pg
run: make dev-build-pg
+4 -4
View File
@@ -1,5 +1,5 @@
# FROM node:24-slim AS base
FROM public.ecr.aws/docker/library/node:24-slim AS base
# FROM node:24.18.1-slim AS base
FROM public.ecr.aws/docker/library/node:24.18.1-slim AS base
WORKDIR /app
@@ -32,8 +32,8 @@ FROM base AS builder
RUN npm ci --omit=dev
# FROM node:24-slim AS runner
FROM public.ecr.aws/docker/library/node:24-slim AS runner
# FROM node:24.18.1-slim AS runner
FROM public.ecr.aws/docker/library/node:24.18.1-slim AS runner
WORKDIR /app
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:24-alpine
FROM node:24.18.1-alpine
WORKDIR /app
+39 -3
View File
@@ -41,7 +41,7 @@
</strong>
</p>
Pangolin is an open-source, identity-based remote access platform built on WireGuard® that enables secure, seamless connectivity to private and public resources. Pangolin combines reverse proxy and VPN capabilities into one platform, providing browser-based access to web applications and client-based access to any private resources with NAT traversal, all with granular access controls.
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.
## Installation
@@ -63,11 +63,26 @@ Pangolin is an open-source, identity-based remote access platform built on WireG
Pangolin's site connectors provide gateways into networks so you can access any networked resources. Sites use outbound tunnels and intelligent NAT traversal to make networks behind restrictive firewalls available for authorized access without public IPs or open ports. Easily deploy a site as a binary or container on any platform.
* Lightweight user-space connector runs anywhere
* Punches through any firewall
* Doesn't require open ports or a public IP
* Strict network segmentation
* WireGuard-based
* Get alerts when a device or network resource goes down
<img src="public/screenshots/sites.png" alt="Sites" width="100%" />
### Browser-based reverse proxy access
Expose web applications through identity and context-aware tunneled reverse proxies. Users access applications through any web browser with authentication and granular access control without installing a client. Pangolin handles routing, load balancing, health checking, and automatic SSL certificates without exposing your network directly to the internet.
Expose HTTPS web applications and connect to VNC, RDP, and SSH entirely in the browser through identity and context-aware tunneled reverse proxies. Users access resources with authentication and granular access control without installing a client. Pangolin handles routing, load balancing, health checking, and automatic SSL certificates without exposing your network directly to the internet.
* Expose a web panel anywhere
* Access via any web browser
* Single sign-on across all resources
* HTTPS resources
* Remote desktop in the browser with VNC and RDP
* In-browser SSH terminal with privileged access management (PAM)
* PIN codes, passcodes, email OTP, geoblocking, allow-lists, and more
<img src="public/clip.gif" alt="Reverse proxy access" width="100%" />
@@ -75,14 +90,35 @@ Expose web applications through identity and context-aware tunneled reverse prox
Access private resources like SSH servers, databases, RDP, and entire network ranges through Pangolin clients. Intelligent NAT traversal enables connections even through restrictive firewalls, while DNS aliases provide friendly names and fast connections to resources across all your sites. Add redundancy by routing traffic through multiple connectors in your network.
* Peer-to-peer with intelligent NAT traversal
* Hosts/IPs and port ranges
* Network ranges/CIDRs
* Friendly DNS aliases for network addresses
* Privileged access management (PAM) with SSH resources
* Private HTTPS resources only accessible on the private network
<img src="public/screenshots/private-resources.png" alt="Private resources" width="100%" />
### Give users and roles access to resources
Use Pangolin's built in users or bring your own identity provider and set up role based access control (RBAC). Grant users access to specific resources, not entire networks. Unlike traditional VPNs that expose full network access, Pangolin's zero-trust model ensures users can only reach the applications, services, and routes you explicitly define.
Use Pangolin's built-in users or bring your own identity provider and set up role-based access control (RBAC). Grant users access to specific resources, not entire networks. Unlike traditional VPNs that expose full network access, Pangolin's zero-trust model ensures users can only reach the applications, services, and routes you explicitly define.
* Bring your existing identity provider (IdP) or use Pangolin identities
* Sync users and roles from your IdP
* User- and role-based access control
* Full network audit and access logs
<img src="public/screenshots/users.png" alt="Users from identity provider with roles" width="100%" />
### Find and launch resources from a personalized home page
Give users a landing page to quickly find and open the resources they can access. Resources are grouped by site or label, searchable, and filterable, with grid or list views. Saved views capture filters, grouping, and layout as personal or organization-wide defaults.
* Single place for admins and non-admins to see accessible resources
* Create reusable views for common access patterns
<img src="public/screenshots/resource-launcher.png" alt="Resource Launcher" width="100%" />
## Download Clients
Download the Pangolin client for your platform:
+101 -1
View File
@@ -1,5 +1,5 @@
import { CommandModule } from "yargs";
import { db, idpOidcConfig, licenseKey, certificates, eventStreamingDestinations, alertWebhookActions } from "@server/db";
import { db, idpOidcConfig, licenseKey, certificates, eventStreamingDestinations, alertWebhookActions, aiProviders, virtualApiKeys } from "@server/db";
import { encrypt, decrypt } from "@server/lib/crypto";
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
import { eq } from "drizzle-orm";
@@ -132,12 +132,16 @@ export const rotateServerSecret: CommandModule<
const certs = await db.select().from(certificates);
const streamingDestinations = await db.select().from(eventStreamingDestinations);
const webhookActions = await db.select().from(alertWebhookActions);
const providers = await db.select().from(aiProviders);
const virtualKeys = await db.select().from(virtualApiKeys);
console.log(`Found ${idpConfigs.length} OIDC IdP configuration(s)`);
console.log(`Found ${licenseKeys.length} license key(s)`);
console.log(`Found ${certs.length} certificate(s)`);
console.log(`Found ${streamingDestinations.length} event streaming destination(s)`);
console.log(`Found ${webhookActions.length} alert webhook action(s)`);
console.log(`Found ${providers.length} AI provider(s)`);
console.log(`Found ${virtualKeys.length} virtual API key(s)`);
// Prepare all decrypted and re-encrypted values
console.log("\nDecrypting and re-encrypting values...");
@@ -171,11 +175,24 @@ export const rotateServerSecret: CommandModule<
encryptedConfig: string;
};
type AiProviderUpdate = {
providerId: number;
encryptedApiKey: string | null;
encryptedHeaders: string | null;
};
type VirtualApiKeyUpdate = {
virtualApiKeyId: string;
encryptedToken: string;
};
const idpUpdates: IdpUpdate[] = [];
const licenseKeyUpdates: LicenseKeyUpdate[] = [];
const certUpdates: CertUpdate[] = [];
const streamingDestinationUpdates: StreamingDestinationUpdate[] = [];
const webhookActionUpdates: WebhookActionUpdate[] = [];
const aiProviderUpdates: AiProviderUpdate[] = [];
const virtualApiKeyUpdates: VirtualApiKeyUpdate[] = [];
// Process idpOidcConfig entries
for (const idpConfig of idpConfigs) {
@@ -306,6 +323,60 @@ export const rotateServerSecret: CommandModule<
}
}
// Process aiProviders entries (apiKey + headers)
for (const provider of providers) {
try {
if (!provider.apiKey && !provider.headers) {
continue;
}
const encryptedApiKey = provider.apiKey
? encrypt(decrypt(provider.apiKey, oldSecret), newSecret)
: null;
const encryptedHeaders = provider.headers
? encrypt(
decrypt(provider.headers, oldSecret),
newSecret
)
: null;
aiProviderUpdates.push({
providerId: provider.providerId,
encryptedApiKey,
encryptedHeaders
});
} catch (error) {
console.error(
`Error processing AI provider ${provider.providerId}:`,
error
);
throw error;
}
}
// Process virtualApiKeys entries (token)
for (const key of virtualKeys) {
try {
if (!key.token) {
continue;
}
virtualApiKeyUpdates.push({
virtualApiKeyId: key.virtualApiKeyId,
encryptedToken: encrypt(
decrypt(key.token, oldSecret),
newSecret
)
});
} catch (error) {
console.error(
`Error processing virtual API key ${key.virtualApiKeyId}:`,
error
);
throw error;
}
}
// Perform all database updates in a single transaction
console.log("\nUpdating database in transaction...");
await db.transaction(async (trx) => {
@@ -376,6 +447,32 @@ export const rotateServerSecret: CommandModule<
)
);
}
// Update AI provider entries
for (const update of aiProviderUpdates) {
await trx
.update(aiProviders)
.set({
apiKey: update.encryptedApiKey,
headers: update.encryptedHeaders
})
.where(eq(aiProviders.providerId, update.providerId));
}
// Update virtual API key entries
for (const update of virtualApiKeyUpdates) {
await trx
.update(virtualApiKeys)
.set({
token: update.encryptedToken
})
.where(
eq(
virtualApiKeys.virtualApiKeyId,
update.virtualApiKeyId
)
);
}
});
console.log(`Rotated ${idpUpdates.length} OIDC IdP configuration(s)`);
@@ -383,6 +480,8 @@ export const rotateServerSecret: CommandModule<
console.log(`Rotated ${certUpdates.length} certificate(s)`);
console.log(`Rotated ${streamingDestinationUpdates.length} event streaming destination(s)`);
console.log(`Rotated ${webhookActionUpdates.length} alert webhook action(s)`);
console.log(`Rotated ${aiProviderUpdates.length} AI provider(s)`);
console.log(`Rotated ${virtualApiKeyUpdates.length} virtual API key(s)`);
// Update config file with new secret
console.log("\nUpdating config file...");
@@ -402,6 +501,7 @@ export const rotateServerSecret: CommandModule<
console.log(` - Certificates: ${certUpdates.length}`);
console.log(` - Event streaming destinations: ${streamingDestinationUpdates.length}`);
console.log(` - Alert webhook actions: ${webhookActionUpdates.length}`);
console.log(` - AI providers: ${aiProviderUpdates.length}`);
console.log(
`\n IMPORTANT: Restart the server for the new secret to take effect.`
);
+1 -1
View File
@@ -41,7 +41,7 @@ services:
- 80:80 # Port for traefik because of the network_mode
traefik:
image: traefik:v3.6
image: traefik:v3.7
container_name: traefik
restart: unless-stopped
network_mode: service:gerbil # Ports appear on the gerbil service
+1 -1
View File
@@ -8,7 +8,7 @@ services:
POSTGRES_USER: postgres # Default user
POSTGRES_PASSWORD: password # Default password (change for production!)
volumes:
- ./config/postgres:/var/lib/postgresql/data
- ${HOME}/.local/share/pangolin-dev/postgres:/var/lib/postgresql/data
ports:
- "5432:5432" # Map host port 5432 to container port 5432
restart: no
+285
View File
@@ -0,0 +1,285 @@
# AI Gateway Provider Selection
How the AI gateway picks which attached provider handles a request when an
inference resource has more than one AI provider.
**Code:**
- Route → capability binding: `server/routers/aiGateway/createAiGatewayRouter.ts`
- Request pipeline: `server/routers/aiGateway/pipeline.ts` (`selectProvider`)
- Tie-break scoring: `server/lib/aiProviderSelection.ts`
- Allow/block matching: `server/lib/aiModelKeyMatch.ts`
- Model catalog: `server/lib/aiModelCatalog.ts`
- Default capabilities per provider type: `server/lib/aiProviderDefaults.ts`
Overlapping model allows are permitted at save time. Selection happens at
request time. If the algorithm cannot confidently pick one provider, the
gateway returns `403` with an ambiguous-provider error.
## Selection Pipeline
Every gateway request runs through these steps in order. Each step narrows
the candidate set. Later steps only run when more than one provider remains.
```
1. Capability filter
2. Allow / block lists
3. Most specific allow pattern
4. Catalog ownership
5. Provider class preference
6. Ambiguous → error
```
### 1. Capability Filter
The incoming path selects a capability before any provider logic runs.
| Path | Capability |
|------|------------|
| `POST /v1/chat/completions` | `openai_chat` |
| `POST /v1/responses` | `openai_responses` |
| `POST /v1/messages` | `anthropic_messages` |
| Gemini / Vertex / Bedrock routes | their respective capability ids |
Only attached providers that advertise that capability stay in the candidate
set. Default capabilities do not overlap for native OpenAI vs Anthropic:
| Provider type | Default capabilities |
|---------------|----------------------|
| `openai` | `openai_chat`, `openai_responses` |
| `anthropic` | `anthropic_messages` |
| `openRouter` | `openai_chat` |
| `vercelAiGateway` | `openai_chat`, `openai_responses` |
| `microsoftFoundry` | `openai_chat`, `openai_responses`, `anthropic_messages` |
| `custom` | whatever was configured |
### 2. Allow / Block Lists
For each remaining provider, the gateway resolves the effective allow and
block patterns:
- **`inherit`**: use the provider's own model lists
- **`select`**: use the resource-selected subset of those lists
A candidate is kept only if `isAllowedByLists(requestedModel, allows, blocks)`
passes:
1. At least one allow pattern must match
2. No block pattern may match
Patterns support `*` and `?` globs (`gpt-*`, `claude-3-5-sonnet-?`).
### 3. Most Specific Allow Pattern
Among providers that allow the model, keep those whose matching allow
pattern is most specific:
1. Exact keys beat patterns
2. Fewer wildcard characters win
3. Longer literal length wins
Example: `gpt-4o` beats `gpt-*` beats `*`.
### 4. Catalog Ownership
When specificity is tied (common with multiple `*` allows), score each
provider against the known model catalog:
| Score | Meaning |
|------:|---------|
| 2 | Typed provider whose catalog contains the model (`openai` → openai catalog, `anthropic` → anthropic, etc.) |
| 1 | Aggregator or custom (`openRouter`, `vercelAiGateway`, `custom`) and the model exists somewhere in the catalog |
| 0 | No ownership signal (typed catalog miss, or unknown model on aggregator/custom) |
Model id lookup tries the raw id, then a stripped `vendor/model` form
(e.g. `openai/gpt-4o` → also try `gpt-4o`).
Typed providers map to catalog providers as:
| Provider type | Catalog |
|---------------|---------|
| `openai` | `openai` |
| `anthropic` | `anthropic` |
| `googleGemini` | `gemini` |
| `vertexAi` | `vertex` |
| `bedrock` | `bedrock` |
| `microsoftFoundry` | `azure` |
| `openRouter` / `vercelAiGateway` / `custom` | none (aggregator/custom path) |
### 5. Provider Class Preference
If catalog ownership is still tied, prefer:
| Rank | Class |
|-----:|-------|
| 2 | Native typed provider (`openai`, `anthropic`, `googleGemini`, …) |
| 1 | Aggregator (`openRouter`, `vercelAiGateway`) |
| 0 | `custom` |
### 6. Ambiguous Error
If more than one distinct provider remains after all steps, the gateway
rejects the request:
```
Model "<id>" is ambiguous across multiple AI providers on this resource
```
Typical remaining ties: two OpenAI-type providers both with `*`, or two
customs advertising the same capability for an unknown model.
## Examples
Assume each provider below is attached and enabled on the same inference
resource.
### Example A: OpenAI + Anthropic, Both `*`
| Provider | Allow | Capabilities |
|----------|-------|--------------|
| OpenAI | `*` | `openai_chat`, `openai_responses` |
| Anthropic | `*` | `anthropic_messages` |
**Request:** `POST /v1/chat/completions` with `model: "gpt-4o"`
1. Capability → only OpenAI remains
2. Allow → OpenAI matches `*`
3. Result → **OpenAI**
Anthropic never reaches pattern or catalog scoring. Capability alone decides.
**Request:** `POST /v1/messages` with `model: "claude-3-5-sonnet-latest"`
1. Capability → only Anthropic remains
2. Result → **Anthropic**
### Example B: OpenAI + OpenRouter, Both `*`
| Provider | Allow | Capabilities |
|----------|-------|--------------|
| OpenAI | `*` | `openai_chat`, … |
| OpenRouter | `*` | `openai_chat` |
**Request:** `POST /v1/chat/completions` with `model: "gpt-4o"`
1. Capability → both remain (`openai_chat`)
2. Allow → both match `*`
3. Specificity → tie (`*` vs `*`)
4. Catalog → OpenAI scores `2` (owns `gpt-4o`); OpenRouter scores `1`
5. Result → **OpenAI**
### Example C: OpenRouter Only Serving a Claude Model Over OpenAI Chat
| Provider | Allow | Capabilities |
|----------|-------|--------------|
| OpenRouter | `*` | `openai_chat` |
**Request:** `POST /v1/chat/completions` with `model: "anthropic/claude-3.5-sonnet"`
1. Capability → OpenRouter remains
2. Only one candidate → **OpenRouter**
No tie-breaking needed.
### Example D: OpenAI (`gpt-*`) + OpenRouter (`*`)
| Provider | Allow |
|----------|-------|
| OpenAI | `gpt-*` |
| OpenRouter | `*` |
**Request:** `model: "gpt-4o"` on `openai_chat`
1. Capability → both
2. Allow → both match
3. Specificity → OpenAI's `gpt-*` beats OpenRouter's `*`
4. Result → **OpenAI**
Catalog scoring is not needed because specificity already unique'd the set.
### Example E: OpenAI + Anthropic With Overlapping Custom Capabilities
Someone grants Anthropic `openai_chat` as well (non-default).
| Provider | Allow | Capabilities |
|----------|-------|--------------|
| OpenAI | `*` | `openai_chat`, … |
| Anthropic | `*` | `anthropic_messages`, `openai_chat` |
**Request:** `POST /v1/chat/completions` with `model: "gpt-4o"`
1. Capability → both remain
2. Allow → both match `*`
3. Specificity → tie
4. Catalog → OpenAI `2`, Anthropic `0` (`gpt-4o` is not in the anthropic catalog)
5. Result → **OpenAI**
### Example F: Two Aggregators, Known Model
| Provider | Allow |
|----------|-------|
| OpenRouter | `*` |
| Vercel AI Gateway | `*` |
**Request:** `model: "gpt-4o"` on `openai_chat`
1. Capability → both
2. Allow / specificity → tie
3. Catalog → both score `1` (known model, no typed owner in the set)
4. Class → both aggregators (rank `1`) → still tied
5. Result → **ambiguous error**
Attach a native OpenAI provider (or narrow one aggregator's allow list) to
make this determinable.
### Example G: Two OpenAI Providers, Both `*`
| Provider | Type | Allow |
|----------|------|-------|
| OpenAI Prod | `openai` | `*` |
| OpenAI Staging | `openai` | `*` |
**Request:** `model: "gpt-4o"`
15 all leave both candidates (same capability, same specificity, same
catalog ownership, same class).
Result → **ambiguous error**
Disambiguate with different allow patterns, disable one attachment, or
split across resources.
### Example H: Unknown Model Across Native + Aggregator
| Provider | Allow |
|----------|-------|
| OpenAI | `*` |
| OpenRouter | `*` |
**Request:** `model: "my-fine-tune-v3"` (not in catalog)
1. Capability → both
2. Allow / specificity → tie
3. Catalog → both score `0` (typed miss + unknown aggregator model)
4. Class → OpenAI (`2`) beats OpenRouter (`1`)
5. Result → **OpenAI**
## Practical Guidance
- Native OpenAI + Anthropic with `*` is safe. Different default APIs never
collide.
- OpenAI + OpenRouter with `*` is usually fine for catalog-known OpenAI
models. Native wins.
- Prefer specific allow patterns (`gpt-4o`, `gpt-*`) when two providers share
a capability.
- Two providers of the same type both using `*` will stay ambiguous. Narrow
at least one allow list.
- Custom providers only win ties when no stronger native/aggregator signal
remains.
## Related Behavior
- **Saving providers on a resource does not reject overlapping allows.**
Collisions are resolved (or rejected) per request.
- Budgets, auth, and upstream URL / target routing run after a single
provider has been selected.
+347
View File
@@ -0,0 +1,347 @@
# How to build a CRUD endpoint in this repo
Reference for adding a new CRUD entity to the server. Based on two real
examples already in the codebase — read them side by side with this doc:
- **Public / open-source (Community Edition) pattern**: `server/routers/aiProvider/`
- **Enterprise-only pattern**: `server/private/routers/alertRule/`
The two are structurally identical. The only difference is *where the files
live* and *which router they get wired into*.
## 1. Decide: public or private?
- `server/routers/<entity>/` — ships in the open-source Community Edition.
Anyone running Pangolin gets this.
- `server/private/routers/<entity>/` — Enterprise/SaaS only. Gated behind
`verifyValidLicense` (and often `verifyValidSubscription(tierMatrix.x)`).
Every file here starts with the Fossorial Commercial License header block
(copy it verbatim from an existing private file).
Everything below applies to both — swap `@server/...` for `#private/...`
import paths and add license headers when building the private version.
## 2. Directory layout
One folder per entity, one file per operation, a barrel `index.ts`:
```
server/routers/<entity>/
index.ts # export * from each operation file + ./types
types.ts # response payload types + row->public mapper
validation.ts # zod schemas/refinements shared by create + update (optional)
create<Entity>.ts
list<Entities>.ts
get<Entity>.ts
update<Entity>.ts
delete<Entity>.ts
```
`index.ts` is a flat barrel:
```ts
export * from "./createAiProvider";
export * from "./listAiProviders";
export * from "./getAiProvider";
export * from "./updateAiProvider";
export * from "./deleteAiProvider";
export * from "./types";
```
## 3. Anatomy of a single handler
Every handler file (`create<Entity>.ts`, etc.) follows the same shape:
```ts
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { <table>, db } from "@server/db";
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 { eq } from "drizzle-orm";
import type { GetXResponse } from "@server/routers/<entity>/types";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty() // or entityId: z.coerce.number().int().positive()
});
const bodySchema = z.strictObject({ /* ... */ }); // create/update only
registry.registerPath({
method: "get", // put | post | delete
path: "/org/{orgId}/x",
description: "...",
tags: [OpenAPITags.<Entity>],
request: { params: paramsSchema, /* body: {...} for write ops, query: for list */ },
responses: { 200: { description: "Successful response" } }
});
export async function getX(req: Request, res: Response, next: NextFunction): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(createHttpError(HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString()));
}
// parse body too, if present, same pattern
// ...business logic against db...
if (!row) {
return next(createHttpError(HttpCode.NOT_FOUND, `X with ID ${id} not found`));
}
return response<GetXResponse>(res, {
data: { /* ... */ },
success: true,
error: false,
message: "X retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred"));
}
}
```
Rules to keep consistent with the rest of the codebase:
- `z.strictObject` for params/body — rejects unknown keys.
- Params parsed first, then body; each on its own `safeParse` + early
`next(createHttpError(...))` — never throw raw errors.
- Every handler registers itself with the OpenAPI `registry` even if nobody
reads the spec directly — it's how `/api/v1/docs` stays accurate.
- Catch-all `try/catch` at the bottom: `logger.error(error)` +
generic `500` message. Never leak internal error details to the client.
- Use `response<T>(res, { data, success, error, message, status })` from
`@server/lib/response` for every response, success or otherwise (errors go
through `next(createHttpError(...))` instead, not through `response`).
- If the route already ran an access-control middleware that fetched the row
(see §5), reuse it instead of re-querying:
`req.aiProvider && req.aiProvider.providerId === providerId ? [req.aiProvider] : await db.select()...`
### List handler specifics
Pagination is a fixed shape (`page`, `pageSize`, optional `query` for
search). See `listAiProviders.ts`:
```ts
const listSchema = z.object({
pageSize: z.coerce.number<string>().int().positive().optional().catch(20).default(20),
page: z.coerce.number<string>().int().min(0).optional().catch(1).default(1),
query: z.string().optional()
});
```
Run the count query and the page query in `Promise.all`, and return
`PaginatedResponse<{ items: T[] }>` (`@server/types/Pagination`) with
`{ total, pageSize, page }`.
### types.ts specifics
- Define one response type per operation: `List<Entities>Response`,
`Get<Entity>Response`, `CreateOrEdit<Entity>Response` (create and update
commonly share a response shape).
- If the raw DB row needs to be shaped for clients (decrypting secrets,
parsing a serialized column, hiding a column), put a `toPublic<Entity>()`
mapper here — see `toPublicAiProvider` for the pattern of stripping
`apiKey`/serialized columns and re-adding decrypted/parsed versions.
### validation.ts specifics
Only needed when create and update share non-trivial zod pieces (enums,
`superRefine` cross-field rules). Export the raw schemas (`z.enum([...])`)
and refinement functions, and import them into both `createX.ts` and
`updateX.ts` — see `aiProvider/validation.ts`'s
`refineProviderUpstreamFields`.
## 4. Wire up an access-control middleware (for id-scoped routes)
For routes scoped to a single row (`/x/:xId`, as opposed to
`/org/:orgId/x` create/list), add a `verify<Entity>Access` middleware in
`server/middlewares/` (or `server/private/middlewares/` for enterprise-only
entities) and export it from that directory's `index.ts`.
Pattern (`verifyAiProviderAccess.ts`):
1. Read the id param, `Number.parseInt`/validate it.
2. Load the row by id.
3. `404` if it doesn't exist.
4. Resolve the row's `orgId`, then check/attach `req.userOrg` (query
`userOrgs` if not already on the request), `403` if the user isn't in
that org.
5. Run `checkOrgAccessPolicy` if `req.orgPolicyAllowed` hasn't been resolved
yet.
6. Set `req.userOrgId`, `req.userOrgRoleIds`, and stash the row on the
request (e.g. `req.aiProvider = provider`) so downstream handlers and
`verifyUserHasAction` don't have to refetch it.
Org-scoped create/list routes (`/org/:orgId/x`) don't need a bespoke
middleware — they use the existing generic `verifyOrgAccess` from
`@server/middlewares`.
## 5. Register an action + permission check
Add one `ActionsEnum` entry per operation in `server/auth/actions.ts`,
grouped near the entity's other actions, named `create<Entity>`,
`get<Entity>`, `update<Entity>`, `delete<Entity>`, `list<Entities>`:
```ts
createAiProvider = "createAiProvider",
deleteAiProvider = "deleteAiProvider",
getAiProvider = "getAiProvider",
listAiProviders = "listAiProviders",
updateAiProvider = "updateAiProvider",
```
Every route uses `verifyUserHasAction(ActionsEnum.x)` to check the caller's
role/permissions for that action, and mutating routes (create/update/delete)
follow it with `logActionAudit(ActionsEnum.x)` to record the action in the
audit log.
## 6. Register the routes
There are four router files; which one(s) you touch depends on public vs.
private and user-facing vs. service-to-service:
| File | Purpose |
|---|---|
| `server/routers/external.ts` | Public, user-facing API. Exports `authenticated`, `unauthenticated`, `authRouter` Express routers. |
| `server/routers/internal.ts` | Public, internal service-to-service API (gerbil, badger, traefik-config) — no user auth, exports `internalRouter`. |
| `server/private/routers/external.ts` | Enterprise-only, user-facing. Imports `authenticated`/`unauthenticated`/`authRouter` **from the public `external.ts`** and re-exports them, then adds more routes on top. |
| `server/private/routers/internal.ts` | Enterprise-only, service-to-service. Same re-export trick with `internalRouter`. |
Private router files always start:
```ts
import {
unauthenticated as ua,
authenticated as a,
authRouter as aa
} from "@server/routers/external";
export const authenticated = a;
export const unauthenticated = ua;
export const authRouter = aa;
```
...and then call `authenticated.get/put/post/delete(...)` to bolt on
additional, enterprise-only routes on the *same* router instances the public
build uses. This is why the private build has strictly more routes than the
public build, not a divergent copy.
### Route registration order (mutating vs read)
Standard middleware chain per verb, using `alertRule`'s registrations as the
template:
```ts
// Create — org-scoped, no row exists yet
authenticated.put(
"/org/:orgId/x",
verifyValidLicense, // private/enterprise routes only
verifyOrgAccess,
verifyLimits, // if the entity counts against a plan limit
verifyUserHasAction(ActionsEnum.createX),
logActionAudit(ActionsEnum.createX),
x.createX
);
// Update — row-scoped
authenticated.post(
"/org/:orgId/x/:xId", // or "/x/:xId" if id is globally unique
verifyValidLicense,
verifyOrgAccess, // or verifyXAccess if globally-keyed
verifyUserHasAction(ActionsEnum.updateX),
logActionAudit(ActionsEnum.updateX),
x.updateX
);
// Delete — row-scoped
authenticated.delete(
"/org/:orgId/x/:xId",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.deleteX),
logActionAudit(ActionsEnum.deleteX),
x.deleteX
);
// List — org-scoped, read-only, no audit log
authenticated.get(
"/org/:orgId/xs",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listXs),
x.listXs
);
// Get one — row-scoped, read-only, no audit log
authenticated.get(
"/org/:orgId/x/:xId",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.getX),
x.getX
);
```
Notes:
- HTTP verbs: `PUT` = create, `POST` = update, `GET` = read, `DELETE` =
delete. This repo does not use `PATCH` for entity updates (site
provisioning keys are the one exception, using `PATCH`).
- `verifyValidLicense` is only needed on private/enterprise routes; public
OSS routes skip it.
- Use `verifyValidSubscription(tierMatrix.someFeature)` right after
`verifyValidLicense` when a feature is gated to specific SaaS tiers (see
`tierMatrix` usages in `server/private/routers/external.ts`).
- `verifyLimits` goes on create routes for entities that count against a
plan/seat limit.
- For entities keyed by a globally-unique id (not nested under `/org/:orgId`),
use the dedicated `verify<Entity>Access` middleware from §4 instead of
`verifyOrgAccess` on the row-scoped routes (see how `/ai-provider/:providerId`
uses `verifyAiProviderAccess`, while `/org/:orgId/ai-provider` create/list
use plain `verifyOrgAccess`).
- Read-only routes (`get`, `list`) skip `logActionAudit` — only mutations are
audited.
- `internal*.ts` routes are for trusted internal callers (gerbil/badger
sidecars) and generally skip user-facing auth entirely, using
`verifySessionUserMiddleware` / `verifyUserFromResourceSessionMiddleware`
instead of `verifyOrgAccess`/`verifyUserHasAction`. CRUD entities almost
never need internal router entries — only add one if a sidecar process
needs direct access to the resource.
## 7. The `#dynamic` alias (advanced — most CRUD work can ignore this)
Some middleware (e.g. `logActionAudit`) needs a real implementation in the
enterprise/SaaS build but a no-op stub in the open-source build, while
being imported by identical code in `server/routers/external.ts` in both
builds. That's done via the `#dynamic/*` import alias, which
`tsconfig.oss.json` points at `./server/*` and `tsconfig.enterprise.json` /
`tsconfig.saas.json` point at `./server/private/*`. You only need this
pattern if you're adding a genuinely dual-implementation hook; a normal
private-only CRUD entity (like `alertRule`) never touches `#dynamic` — it
just lives entirely under `server/private/` and is imported with `#private/*`
directly from `server/private/routers/external.ts`.
## 8. Checklist for a new entity
1. Add the DB table to `server/db/pg/schema/schema.ts` (and sqlite schema if
applicable).
2. Add `ActionsEnum` entries in `server/auth/actions.ts`.
3. Create `server/routers/<entity>/` (or `server/private/routers/<entity>/`):
`types.ts`, optional `validation.ts`, one file per operation, `index.ts`
barrel.
4. If routes are row-scoped by a global id, add
`verify<Entity>Access.ts` to `server/middlewares/` or
`server/private/middlewares/`, and export it from that directory's
`index.ts`.
5. Wire routes into `external.ts` (public or private) following the verb/
middleware table in §6. Add to `internal.ts` only if a sidecar needs
direct access.
6. Add license header block to every new file if it's under `server/private/`.
+1 -1
View File
@@ -50,7 +50,7 @@ services:
- 80:80{{end}}
traefik:
image: docker.io/traefik:v3.6
image: docker.io/traefik:v3.7
container_name: traefik
restart: unless-stopped
{{if .InstallGerbil}}network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}}
+2 -2
View File
@@ -5,7 +5,7 @@ go 1.25.0
require (
github.com/charmbracelet/huh v1.0.0
github.com/charmbracelet/lipgloss v1.1.0
golang.org/x/term v0.44.0
golang.org/x/term v0.45.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -33,6 +33,6 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.15.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.23.0 // indirect
)
+4 -4
View File
@@ -69,10 +69,10 @@ golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+1 -1
View File
@@ -76,7 +76,7 @@ var redisFlag *bool
func main() {
crowdsecFlag := flag.Bool("crowdsec", false, "Enable the CrowdSec installation prompt")
redisFlag = flag.Bool("redis", false, "Install Redis as cacheing solution. Required for HA. Not required for the Enterprise version.")
redisFlag = flag.Bool("redis", false, "Install Redis as caching solution. Required for HA. Not required for the Enterprise version.")
flag.Parse()
// print a banner about prerequisites - opening port 80, 443, 51820, and 21820 on the VPS and firewall and pointing your domain to the VPS IP with a records. Docs are at http://localhost:3000/Getting%20Started/dns-networking
+772 -29
View File
File diff suppressed because it is too large Load Diff
+771 -28
View File
File diff suppressed because it is too large Load Diff
+770 -27
View File
File diff suppressed because it is too large Load Diff
+774 -31
View File
File diff suppressed because it is too large Load Diff
+772 -30
View File
File diff suppressed because it is too large Load Diff
+773 -30
View File
File diff suppressed because it is too large Load Diff
+771 -28
View File
File diff suppressed because it is too large Load Diff
+773 -30
View File
File diff suppressed because it is too large Load Diff
+770 -27
View File
File diff suppressed because it is too large Load Diff
+773 -30
View File
File diff suppressed because it is too large Load Diff
+809 -66
View File
File diff suppressed because it is too large Load Diff
+770 -27
View File
File diff suppressed because it is too large Load Diff
+773 -30
View File
File diff suppressed because it is too large Load Diff
+771 -28
View File
File diff suppressed because it is too large Load Diff
+773 -30
View File
File diff suppressed because it is too large Load Diff
+772 -29
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1099,6 +1099,7 @@
"actionGenerateAccessToken": "生成訪問令牌",
"actionDeleteAccessToken": "刪除訪問令牌",
"actionListAccessTokens": "訪問令牌",
"actionCreateResourceSessionToken": "建立資源工作階段權杖",
"actionCreateResourceRule": "創建資源規則",
"actionDeleteResourceRule": "刪除資源規則",
"actionListResourceRules": "列出資源規則",
+2540 -3497
View File
File diff suppressed because it is too large Load Diff
+75 -74
View File
@@ -32,49 +32,49 @@
"format": "prettier --write ."
},
"dependencies": {
"@asteasolutions/zod-to-openapi": "8.5.0",
"@asteasolutions/zod-to-openapi": "9.1.0",
"@aws-sdk/client-s3": "3.1111.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.0.tgz",
"@aws-sdk/client-s3": "3.1056.0",
"@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.0",
"@monaco-editor/react": "4.7.0",
"@node-rs/argon2": "2.0.2",
"@node-rs/argon2": "2.1.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.2",
"@tailwindcss/forms": "0.5.11",
"@tanstack/react-query": "5.100.14",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-query": "5.101.4",
"@tanstack/react-table": "9.1.2",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"arctic": "3.7.0",
"axios": "1.16.1",
"axios": "1.19.0",
"better-sqlite3": "11.9.1",
"canvas-confetti": "1.9.4",
"class-variance-authority": "0.7.1",
@@ -86,61 +86,62 @@
"d3": "7.9.0",
"drizzle-orm": "0.45.2",
"express": "5.2.1",
"express-rate-limit": "8.5.2",
"express-rate-limit": "8.6.2",
"glob": "13.0.6",
"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",
"ioredis": "6.0.0",
"jmespath": "0.16.0",
"js-yaml": "4.2.0",
"js-yaml": "5.3.0",
"jsonwebtoken": "9.0.3",
"lucide-react": "1.17.0",
"maxmind": "5.0.6",
"lucide-react": "1.31.0",
"maxmind": "5.0.7",
"moment": "2.30.1",
"next": "16.2.6",
"next-intl": "4.13.0",
"next": "16.3.1",
"next-intl": "4.13.6",
"next-themes": "0.4.6",
"nextjs-toploader": "3.9.17",
"node-cache": "5.1.2",
"nodemailer": "9.0.1",
"nodemailer": "9.0.5",
"oslo": "1.2.1",
"pg": "8.21.0",
"posthog-node": "5.35.6",
"pg": "8.23.0",
"posthog-node": "5.49.1",
"qrcode.react": "4.2.0",
"react": "19.2.6",
"react-day-picker": "9.14.0",
"react-dom": "19.2.6",
"react": "19.2.8",
"react-day-picker": "10.0.1",
"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.85.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.5.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.1",
"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",
"yargs": "18.1.0",
"zod": "4.4.3",
"zod-validation-error": "5.0.0"
},
"devDependencies": {
"@dotenvx/dotenvx": "1.69.1",
"@dotenvx/dotenvx": "2.21.0",
"@esbuild-plugins/tsconfig-paths": "0.1.2",
"@react-email/ui": "^6.5.0",
"@tailwindcss/postcss": "4.3.0",
"@tanstack/react-query-devtools": "5.100.14",
"@react-email/ui": "^6.9.2",
"@tailwindcss/postcss": "4.3.3",
"@tanstack/react-query-devtools": "5.101.4",
"@types/better-sqlite3": "7.6.13",
"@types/cookie-parser": "1.4.10",
"@types/cors": "2.8.19",
@@ -151,36 +152,36 @@
"@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.2.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.21.0",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.4",
"@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.1",
"esbuild-node-externals": "1.22.0",
"eslint": "10.4.0",
"eslint-config-next": "16.2.6",
"postcss": "8.5.15",
"prettier": "3.8.3",
"react-email": "6.5.0",
"tailwindcss": "4.3.0",
"tsc-alias": "1.8.17",
"tsx": "4.22.3",
"typescript": "6.0.3",
"typescript-eslint": "8.60.0"
"esbuild": "0.28.2",
"esbuild-node-externals": "2.0.0",
"eslint": "10.8.1",
"eslint-config-next": "16.3.1",
"postcss": "8.5.26",
"prettier": "3.9.6",
"react-email": "6.9.2",
"tailwindcss": "4.3.3",
"tsc-alias": "1.9.1",
"tsx": "4.23.12",
"typescript": "7.0.2",
"typescript-eslint": "8.67.0"
},
"overrides": {
"esbuild": "0.28.1",
"esbuild": "0.28.2",
"dompurify": "3.4.0",
"postcss": "8.5.15"
"postcss": "8.5.26"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 556 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg width="256" height="257" viewBox="0 0 256 257" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_29_2)">
<path d="M50.2278 170.321L100.585 142.064L101.428 139.601L100.585 138.24H98.1225L89.6972 137.722L60.9215 136.944L35.9696 135.907L11.7954 134.611L5.70329 133.314L0 125.796L0.583291 122.037L5.70329 118.603L13.0268 119.251L29.2294 120.352L53.5332 122.037L71.1615 123.074L97.28 125.796H101.428L102.011 124.111L100.585 123.074L99.4835 122.037L74.3372 104.992L47.117 86.9752L32.8587 76.6056L25.1463 71.3559L21.2577 66.4304L19.5727 55.6719L26.5722 47.9595L35.9696 48.6076L38.3676 49.2557L47.8947 56.5792L68.2451 72.3281L94.8172 91.9008L98.7058 95.1413L100.261 94.0395L100.456 93.2618L98.7058 90.3453L84.2532 64.2268L68.8284 37.6547L61.9585 26.637L60.1438 20.0263C59.4957 17.3043 59.042 15.0359 59.042 12.2491L67.0137 1.42582L71.4208 0L82.0496 1.42582L86.5215 5.31443L93.1321 20.4152L103.826 44.2005L120.417 76.5408L125.278 86.1327L127.87 95.0116L128.843 97.7337H130.528V96.1782L131.889 77.9666L134.416 55.6071L136.879 26.8314L137.722 18.7301L141.74 9.00861L149.711 3.75899L155.933 6.74025L161.053 14.0638L160.34 18.7949L157.294 38.562L151.332 69.5413L147.443 90.2805H149.711L152.304 87.6881L162.803 73.7539L180.431 51.7185L188.209 42.9691L197.282 33.3124L203.115 28.7109H214.133L222.234 40.7656L218.605 53.2091L207.263 67.597L197.865 79.7813L184.385 97.9281L175.959 112.446L176.737 113.612L178.746 113.418L209.207 106.937L225.669 103.955L245.306 100.585L254.185 104.733L255.157 108.946L251.658 117.566L230.659 122.75L206.031 127.676L169.349 136.361L168.895 136.685L169.414 137.333L185.94 138.888L193.005 139.277H210.309L242.52 141.675L250.945 147.249L256 154.054L255.157 159.238L242.195 165.849L224.697 161.701L183.866 151.98L169.867 148.48H167.923V149.647L179.589 161.053L200.976 180.367L227.743 205.254L229.104 211.411L225.669 216.271L222.039 215.753L198.513 198.06L189.44 190.088L168.895 172.784H167.534V174.598L172.265 181.533L197.282 219.123L198.578 230.659L196.764 234.418L190.283 236.687L183.153 235.39L168.506 214.846L153.406 191.708L141.221 170.969L139.731 171.812L132.537 249.26L129.167 253.213L121.389 256.194L114.908 251.269L111.473 243.297L114.908 227.548L119.056 207.004L122.426 190.671L125.472 170.386L127.287 163.646L127.157 163.192L125.667 163.386L110.372 184.385L87.1048 215.818L68.6987 235.52L64.2916 237.27L56.6441 233.316L57.357 226.252L61.6344 219.966L87.1048 187.561L102.465 167.469L112.381 155.868L112.316 154.183H111.733L44.0709 198.125L32.0162 199.68L26.8314 194.819L27.4795 186.848L29.9423 184.255L50.2927 170.256L50.2278 170.321Z" fill="#0B0B0B"/>
</g>
<defs>
<clipPath id="clip0_29_2">
<rect width="256" height="257" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg width="256" height="257" viewBox="0 0 256 257" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_29_2)">
<path d="M50.2278 170.321L100.585 142.064L101.428 139.601L100.585 138.24H98.1225L89.6972 137.722L60.9215 136.944L35.9696 135.907L11.7954 134.611L5.70329 133.314L0 125.796L0.583291 122.037L5.70329 118.603L13.0268 119.251L29.2294 120.352L53.5332 122.037L71.1615 123.074L97.28 125.796H101.428L102.011 124.111L100.585 123.074L99.4835 122.037L74.3372 104.992L47.117 86.9752L32.8587 76.6056L25.1463 71.3559L21.2577 66.4304L19.5727 55.6719L26.5722 47.9595L35.9696 48.6076L38.3676 49.2557L47.8947 56.5792L68.2451 72.3281L94.8172 91.9008L98.7058 95.1413L100.261 94.0395L100.456 93.2618L98.7058 90.3453L84.2532 64.2268L68.8284 37.6547L61.9585 26.637L60.1438 20.0263C59.4957 17.3043 59.042 15.0359 59.042 12.2491L67.0137 1.42582L71.4208 0L82.0496 1.42582L86.5215 5.31443L93.1321 20.4152L103.826 44.2005L120.417 76.5408L125.278 86.1327L127.87 95.0116L128.843 97.7337H130.528V96.1782L131.889 77.9666L134.416 55.6071L136.879 26.8314L137.722 18.7301L141.74 9.00861L149.711 3.75899L155.933 6.74025L161.053 14.0638L160.34 18.7949L157.294 38.562L151.332 69.5413L147.443 90.2805H149.711L152.304 87.6881L162.803 73.7539L180.431 51.7185L188.209 42.9691L197.282 33.3124L203.115 28.7109H214.133L222.234 40.7656L218.605 53.2091L207.263 67.597L197.865 79.7813L184.385 97.9281L175.959 112.446L176.737 113.612L178.746 113.418L209.207 106.937L225.669 103.955L245.306 100.585L254.185 104.733L255.157 108.946L251.658 117.566L230.659 122.75L206.031 127.676L169.349 136.361L168.895 136.685L169.414 137.333L185.94 138.888L193.005 139.277H210.309L242.52 141.675L250.945 147.249L256 154.054L255.157 159.238L242.195 165.849L224.697 161.701L183.866 151.98L169.867 148.48H167.923V149.647L179.589 161.053L200.976 180.367L227.743 205.254L229.104 211.411L225.669 216.271L222.039 215.753L198.513 198.06L189.44 190.088L168.895 172.784H167.534V174.598L172.265 181.533L197.282 219.123L198.578 230.659L196.764 234.418L190.283 236.687L183.153 235.39L168.506 214.846L153.406 191.708L141.221 170.969L139.731 171.812L132.537 249.26L129.167 253.213L121.389 256.194L114.908 251.269L111.473 243.297L114.908 227.548L119.056 207.004L122.426 190.671L125.472 170.386L127.287 163.646L127.157 163.192L125.667 163.386L110.372 184.385L87.1048 215.818L68.6987 235.52L64.2916 237.27L56.6441 233.316L57.357 226.252L61.6344 219.966L87.1048 187.561L102.465 167.469L112.381 155.868L112.316 154.183H111.733L44.0709 198.125L32.0162 199.68L26.8314 194.819L27.4795 186.848L29.9423 184.255L50.2927 170.256L50.2278 170.321Z" fill="#F0EFEC"/>
</g>
<defs>
<clipPath id="clip0_29_2">
<rect width="256" height="257" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Ebene_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 466.73 532.09">
<!-- Generator: Adobe Illustrator 29.6.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 9) -->
<defs>
<style>
.st0 {
fill: #26251e;
}
</style>
</defs>
<path class="st0" d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"/>
</svg>

After

Width:  |  Height:  |  Size: 793 B

+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Ebene_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 466.73 532.09">
<!-- Generator: Adobe Illustrator 29.6.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 9) -->
<defs>
<style>
.st0 {
fill: #edecec;
}
</style>
</defs>
<path class="st0" d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"/>
</svg>

After

Width:  |  Height:  |  Size: 793 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="716" height="716" viewBox="157 157 402 402" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M508.749 317.399C516.777 287.314 508.991 253.884 485.389 230.282C461.788 206.681 428.36 198.895 398.273 206.923C376.231 184.928 343.39 174.956 311.148 183.596C278.906 192.234 255.45 217.292 247.36 247.361C217.291 255.451 192.233 278.91 183.595 311.149C174.957 343.391 184.927 376.232 206.924 398.274C198.896 428.359 206.683 461.789 230.284 485.391C253.885 508.992 287.313 516.779 317.401 508.75C339.442 530.745 372.286 540.717 404.525 532.079C436.767 523.441 460.223 498.384 468.313 468.315C498.383 460.224 523.44 436.766 532.078 404.526C540.716 372.285 530.747 339.443 508.749 317.402V317.399ZM470.899 244.776C486.892 260.77 493.488 282.601 490.687 303.412L415.577 260.046C412.411 258.218 408.509 258.218 405.345 260.046L317.401 310.82V277.526C317.401 275.191 318.652 273.005 320.676 271.837L387.644 233.174C414.178 218.353 448.346 222.223 470.901 244.776H470.899ZM357.837 311.144L398.275 334.491V381.185L357.837 404.532L317.398 381.185V334.491L357.837 311.144ZM264.776 269.693C265.207 239.305 285.644 211.649 316.453 203.393C338.3 197.54 360.505 202.744 377.127 215.573L302.014 258.937C298.848 260.764 296.898 264.144 296.898 267.798V369.346L268.065 352.699C266.043 351.531 264.776 349.353 264.776 347.017V269.691V269.693ZM203.391 316.454C209.244 294.608 224.854 277.978 244.276 269.999V356.73C244.276 360.384 246.226 363.763 249.392 365.591L337.337 416.365L308.503 433.013C306.481 434.181 303.961 434.188 301.939 433.02L234.971 394.357C208.868 378.789 195.138 347.261 203.391 316.454ZM244.775 470.9C228.781 454.906 222.186 433.075 224.986 412.264L300.096 455.63C303.263 457.457 307.164 457.457 310.328 455.63L398.273 404.856V438.149C398.273 440.485 397.022 442.671 394.997 443.839L328.029 482.502C301.495 497.322 267.327 493.452 244.772 470.9H244.775ZM450.897 445.982C450.466 476.371 430.029 504.027 399.22 512.283C377.373 518.136 355.168 512.932 338.547 500.102L413.659 456.738C416.826 454.911 418.775 451.532 418.775 447.877V346.329L447.609 362.977C449.631 364.145 450.897 366.323 450.897 368.659V445.985V445.982ZM512.282 399.221C506.429 421.068 490.819 437.697 471.397 445.676V358.946C471.397 355.292 469.448 351.912 466.281 350.085L378.336 299.311L407.17 282.663C409.192 281.495 411.712 281.487 413.734 282.655L480.702 321.318C506.805 336.887 520.536 368.415 512.282 399.221Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg width="716" height="716" viewBox="157 157 402 402" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M508.749 317.399C516.777 287.314 508.991 253.884 485.389 230.282C461.788 206.681 428.36 198.895 398.273 206.923C376.231 184.928 343.39 174.956 311.148 183.596C278.906 192.234 255.45 217.292 247.36 247.361C217.291 255.451 192.233 278.91 183.595 311.149C174.957 343.391 184.927 376.232 206.924 398.274C198.896 428.359 206.683 461.789 230.284 485.391C253.885 508.992 287.313 516.779 317.401 508.75C339.442 530.745 372.286 540.717 404.525 532.079C436.767 523.441 460.223 498.384 468.313 468.315C498.383 460.224 523.44 436.766 532.078 404.526C540.716 372.285 530.747 339.443 508.749 317.402V317.399ZM470.899 244.776C486.892 260.77 493.488 282.601 490.687 303.412L415.577 260.046C412.411 258.218 408.509 258.218 405.345 260.046L317.401 310.82V277.526C317.401 275.191 318.652 273.005 320.676 271.837L387.644 233.174C414.178 218.353 448.346 222.223 470.901 244.776H470.899ZM357.837 311.144L398.275 334.491V381.185L357.837 404.532L317.398 381.185V334.491L357.837 311.144ZM264.776 269.693C265.207 239.305 285.644 211.649 316.453 203.393C338.3 197.54 360.505 202.744 377.127 215.573L302.014 258.937C298.848 260.764 296.898 264.144 296.898 267.798V369.346L268.065 352.699C266.043 351.531 264.776 349.353 264.776 347.017V269.691V269.693ZM203.391 316.454C209.244 294.608 224.854 277.978 244.276 269.999V356.73C244.276 360.384 246.226 363.763 249.392 365.591L337.337 416.365L308.503 433.013C306.481 434.181 303.961 434.188 301.939 433.02L234.971 394.357C208.868 378.789 195.138 347.261 203.391 316.454ZM244.775 470.9C228.781 454.906 222.186 433.075 224.986 412.264L300.096 455.63C303.263 457.457 307.164 457.457 310.328 455.63L398.273 404.856V438.149C398.273 440.485 397.022 442.671 394.997 443.839L328.029 482.502C301.495 497.322 267.327 493.452 244.772 470.9H244.775ZM450.897 445.982C450.466 476.371 430.029 504.027 399.22 512.283C377.373 518.136 355.168 512.932 338.547 500.102L413.659 456.738C416.826 454.911 418.775 451.532 418.775 447.877V346.329L447.609 362.977C449.631 364.145 450.897 366.323 450.897 368.659V445.985V445.982ZM512.282 399.221C506.429 421.068 490.819 437.697 471.397 445.676V358.946C471.397 355.292 469.448 351.912 466.281 350.085L378.336 299.311L407.17 282.663C409.192 281.495 411.712 281.487 413.734 282.655L480.702 321.318C506.805 336.887 520.536 368.415 512.282 399.221Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

+1
View File
@@ -0,0 +1 @@
<svg width='240' height='300' viewBox='0 0 240 300' fill='none' xmlns='http://www.w3.org/2000/svg'><g clip-path='url(#clip0_1401_86274)'><mask id='mask0_1401_86274' style='mask-type:luminance' maskUnits='userSpaceOnUse' x='0' y='0' width='240' height='300'><path d='M240 0H0V300H240V0Z' fill='white'/></mask><g mask='url(#mask0_1401_86274)'><path d='M180 240H60V120H180V240Z' fill='#CFCECD'/><path d='M180 60H60V240H180V60ZM240 300H0V0H240V300Z' fill='#211E1E'/></g></g><defs><clipPath id='clip0_1401_86274'><rect width='240' height='300' fill='white'/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 577 B

+1
View File
@@ -0,0 +1 @@
<svg width='240' height='300' viewBox='0 0 240 300' fill='none' xmlns='http://www.w3.org/2000/svg'><g clip-path='url(#clip0_1401_86283)'><mask id='mask0_1401_86283' style='mask-type:luminance' maskUnits='userSpaceOnUse' x='0' y='0' width='240' height='300'><path d='M240 0H0V300H240V0Z' fill='white'/></mask><g mask='url(#mask0_1401_86283)'><path d='M180 240H60V120H180V240Z' fill='#4B4646'/><path d='M180 60H60V240H180V60ZM240 300H0V0H240V300Z' fill='#F1ECEC'/></g></g><defs><clipPath id='clip0_1401_86283'><rect width='240' height='300' fill='white'/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 577 B

+39
View File
@@ -0,0 +1,39 @@
import express from "express";
import helmet from "helmet";
import cors from "cors";
import config from "@server/lib/config";
import logger from "@server/logger";
import {
errorHandlerMiddleware,
notFoundMiddleware
} from "@server/middlewares";
import { createAiGatewayRouter } from "@server/routers/aiGateway";
const aiGatewayPort = config.getRawConfig().server.ai_gateway_port;
export function createAiGatewayServer() {
const aiGatewayServer = express();
const trustProxy = config.getRawConfig().server.trust_proxy;
if (trustProxy) {
aiGatewayServer.set("trust proxy", trustProxy);
}
aiGatewayServer.use(helmet());
aiGatewayServer.use(cors());
aiGatewayServer.use(express.json());
aiGatewayServer.use(createAiGatewayRouter());
aiGatewayServer.use(notFoundMiddleware);
aiGatewayServer.use(errorHandlerMiddleware);
aiGatewayServer.listen(aiGatewayPort, (err?: any) => {
if (err) throw err;
logger.info(
`AI gateway server is running on http://localhost:${aiGatewayPort}`
);
});
return aiGatewayServer;
}
+28 -1
View File
@@ -21,6 +21,8 @@ export enum ActionsEnum {
getSite = "getSite",
listSites = "listSites",
updateSite = "updateSite",
updateSiteApprovals = "updateSiteApprovals",
restartSite = "restartSite",
resetSiteBandwidth = "resetSiteBandwidth",
reGenerateSecret = "reGenerateSecret",
createResource = "createResource",
@@ -48,6 +50,8 @@ export enum ActionsEnum {
setResourceUsers = "setResourceUsers",
setResourceRoles = "setResourceRoles",
listResourceUsers = "listResourceUsers",
listResourceAiModels = "listResourceAiModels",
setResourceAiModels = "setResourceAiModels",
// removeRoleSite = "removeRoleSite",
// addRoleAction = "addRoleAction",
// removeRoleAction = "removeRoleAction",
@@ -70,6 +74,7 @@ export enum ActionsEnum {
setResourceWhitelist = "setResourceWhitelist",
getResourceWhitelist = "getResourceWhitelist",
generateAccessToken = "generateAccessToken",
createResourceSessionToken = "createResourceSessionToken",
deleteAcessToken = "deleteAcessToken",
listAccessTokens = "listAccessTokens",
createResourceRule = "createResourceRule",
@@ -148,6 +153,7 @@ export enum ActionsEnum {
createAlertRule = "createAlertRule",
updateAlertRule = "updateAlertRule",
deleteAlertRule = "deleteAlertRule",
testAlertRule = "testAlertRule",
listAlertRules = "listAlertRules",
listOrgLabels = "listOrgLabels",
createOrgLabel = "createOrgLabel",
@@ -178,7 +184,28 @@ export enum ActionsEnum {
setResourcePolicyPincode = "setResourcePolicyPincode",
setResourcePolicyHeaderAuth = "setResourcePolicyHeaderAuth",
setResourcePolicyWhitelist = "setResourcePolicyWhitelist",
setResourcePolicyRules = "setResourcePolicyRules"
setResourcePolicyRules = "setResourcePolicyRules",
createOrgWideLauncherView = "createOrgWideLauncherView",
createAiProvider = "createAiProvider",
deleteAiProvider = "deleteAiProvider",
getAiProvider = "getAiProvider",
listAiProviders = "listAiProviders",
updateAiProvider = "updateAiProvider",
createAiModel = "createAiModel",
deleteAiModel = "deleteAiModel",
getAiModel = "getAiModel",
listAiModels = "listAiModels",
updateAiModel = "updateAiModel",
createAiBudget = "createAiBudget",
deleteAiBudget = "deleteAiBudget",
getAiBudget = "getAiBudget",
listAiBudgets = "listAiBudgets",
updateAiBudget = "updateAiBudget",
createVirtualApiKey = "createVirtualApiKey",
deleteVirtualApiKey = "deleteVirtualApiKey",
getVirtualApiKey = "getVirtualApiKey",
listVirtualApiKeys = "listVirtualApiKeys",
updateVirtualApiKey = "updateVirtualApiKey"
}
export async function checkUserActionPermission(
+311
View File
@@ -0,0 +1,311 @@
import { canUserAccessResource } from "@server/auth/canUserAccessResource";
import {
db,
users,
virtualApiKeyResources,
virtualApiKeys,
type VirtualApiKey
} from "@server/db";
import config from "@server/lib/config";
import {
decryptVirtualApiKeyToken,
VIRTUAL_API_KEY_PREFIX,
looksLikeVirtualApiKeyCredential
} from "@server/lib/virtualApiKey";
import { getUserOrgRoles } from "@server/lib/userOrgRoles";
import { and, eq } from "drizzle-orm";
import { isWithinExpirationDate } from "oslo";
export type VirtualApiKeyCredential = {
virtualApiKeyId: string;
secret: string;
};
export type VirtualApiKeyUserData = {
userId: string;
username: string;
email: string | null;
name: string | null;
role: string | null;
};
function getHeader(
headers: Record<string, string> | undefined,
name: string
): string | undefined {
if (!headers) {
return undefined;
}
if (headers[name] !== undefined) {
return headers[name];
}
const lower = name.toLowerCase();
for (const [key, value] of Object.entries(headers)) {
if (key.toLowerCase() === lower) {
return value;
}
}
return undefined;
}
function parseVkCredential(
raw: string | undefined
): VirtualApiKeyCredential | null {
if (!raw || !looksLikeVirtualApiKeyCredential(raw)) {
return null;
}
const withoutPrefix = raw.trim().slice(VIRTUAL_API_KEY_PREFIX.length);
const dot = withoutPrefix.indexOf(".");
return {
virtualApiKeyId: withoutPrefix.slice(0, dot),
secret: withoutPrefix.slice(dot + 1)
};
}
export function extractVirtualApiKeyCredential(
headers: Record<string, string> | undefined
): VirtualApiKeyCredential | null {
if (!headers) {
return null;
}
const authorization = getHeader(headers, "authorization");
if (authorization) {
const bearerMatch = authorization.match(/^Bearer\s+(.+)$/i);
if (bearerMatch) {
const credential = parseVkCredential(bearerMatch[1]);
if (credential) {
return credential;
}
}
const splunkMatch = authorization.match(/^Splunk\s+(.+)$/i);
if (splunkMatch) {
const credential = parseVkCredential(splunkMatch[1]);
if (credential) {
return credential;
}
}
}
const cfAig = getHeader(headers, "cf-aig-authorization");
if (cfAig) {
const bearerMatch = cfAig.match(/^Bearer\s+(.+)$/i);
const credential = parseVkCredential(
bearerMatch ? bearerMatch[1] : cfAig
);
if (credential) {
return credential;
}
}
for (const name of ["x-api-key", "x-goog-api-key"] as const) {
const credential = parseVkCredential(getHeader(headers, name));
if (credential) {
return credential;
}
}
return null;
}
async function buildUserData(
userId: string,
orgId: string
): Promise<VirtualApiKeyUserData | undefined> {
const [user] = await db
.select()
.from(users)
.where(eq(users.userId, userId))
.limit(1);
if (!user) {
return undefined;
}
if (
config.getRawConfig().flags?.require_email_verification &&
!user.emailVerified
) {
return undefined;
}
const userOrgRoles = await getUserOrgRoles(user.userId, orgId);
if (userOrgRoles.length === 0) {
return undefined;
}
return {
userId: user.userId,
username: user.username,
email: user.email,
name: user.name,
role: userOrgRoles.map((r) => r.roleName).join(", ") || null
};
}
async function userHasResourceAccess(
userId: string,
resourceId: number,
orgId: string
): Promise<{ allowed: boolean; userData?: VirtualApiKeyUserData }> {
const [user] = await db
.select()
.from(users)
.where(eq(users.userId, userId))
.limit(1);
if (!user) {
return { allowed: false };
}
if (
config.getRawConfig().flags?.require_email_verification &&
!user.emailVerified
) {
return { allowed: false };
}
const userOrgRoles = await getUserOrgRoles(user.userId, orgId);
if (userOrgRoles.length === 0) {
return { allowed: false };
}
const allowed = await canUserAccessResource({
userId,
resourceId,
roleIds: userOrgRoles.map((r) => r.roleId)
});
if (!allowed) {
return { allowed: false };
}
return {
allowed: true,
userData: {
userId: user.userId,
username: user.username,
email: user.email,
name: user.name,
role: userOrgRoles.map((r) => r.roleName).join(", ") || null
}
};
}
async function manualKeyHasResourceAccess(
key: VirtualApiKey,
resourceId: number
): Promise<boolean> {
if (key.allResources) {
return true;
}
const [row] = await db
.select({ resourceId: virtualApiKeyResources.resourceId })
.from(virtualApiKeyResources)
.where(
and(
eq(virtualApiKeyResources.virtualApiKeyId, key.virtualApiKeyId),
eq(virtualApiKeyResources.resourceId, resourceId)
)
)
.limit(1);
return Boolean(row);
}
async function touchLastUsedAt(virtualApiKeyId: string): Promise<void> {
try {
await db
.update(virtualApiKeys)
.set({ lastUsedAt: Date.now() })
.where(eq(virtualApiKeys.virtualApiKeyId, virtualApiKeyId));
} catch {
// Best-effort; do not fail auth on audit timestamp updates.
}
}
export async function verifyVirtualApiKey({
credential,
resourceId,
orgId
}: {
credential: VirtualApiKeyCredential;
resourceId: number;
orgId: string;
}): Promise<{
valid: boolean;
error?: string;
key?: VirtualApiKey;
userData?: VirtualApiKeyUserData;
}> {
const [key] = await db
.select()
.from(virtualApiKeys)
.where(eq(virtualApiKeys.virtualApiKeyId, credential.virtualApiKeyId))
.limit(1);
if (!key) {
return { valid: false, error: "Virtual API key not found" };
}
if (key.orgId !== orgId) {
return { valid: false, error: "Virtual API key org mismatch" };
}
let plaintext: string;
try {
plaintext = decryptVirtualApiKeyToken(key.token);
} catch {
return { valid: false, error: "Virtual API key secret is invalid" };
}
if (plaintext !== credential.secret) {
return { valid: false, error: "Invalid virtual API key secret" };
}
if (key.expiresAt && !isWithinExpirationDate(new Date(key.expiresAt))) {
return { valid: false, error: "Virtual API key has expired" };
}
if (key.kind === "manual") {
const scoped = await manualKeyHasResourceAccess(key, resourceId);
if (!scoped) {
return {
valid: false,
error: "Virtual API key is not scoped to this resource"
};
}
let userData: VirtualApiKeyUserData | undefined;
if (key.userId) {
userData = await buildUserData(key.userId, orgId);
}
await touchLastUsedAt(key.virtualApiKeyId);
return { valid: true, key, userData };
}
if (key.kind === "user") {
if (!key.userId) {
return { valid: false, error: "User virtual API key has no user" };
}
const access = await userHasResourceAccess(
key.userId,
resourceId,
orgId
);
if (!access.allowed || !access.userData) {
return {
valid: false,
error: "User is not allowed to access this resource"
};
}
await touchLastUsedAt(key.virtualApiKeyId);
return { valid: true, key, userData: access.userData };
}
return { valid: false, error: "Unknown virtual API key kind" };
}
+4
View File
@@ -3,12 +3,16 @@ import { flushConnectionLogToDb } from "#dynamic/routers/newt";
import { flushSiteBandwidthToDb } from "@server/routers/gerbil/receiveBandwidth";
import { stopPingAccumulator } from "@server/routers/newt/pingAccumulator";
import { cleanup as wsCleanup } from "#dynamic/routers/ws";
import { shutdownUsageRecorder } from "@server/lib/aiBudgetEnforcement";
import { shutdownAiSessionLogger } from "@server/routers/aiGateway/logAiSession";
async function cleanup() {
await stopPingAccumulator();
await flushBandwidthToDb();
await flushConnectionLogToDb();
await flushSiteBandwidthToDb();
await shutdownUsageRecorder();
await shutdownAiSessionLogger();
await wsCleanup();
process.exit(0);
+27
View File
@@ -1,6 +1,7 @@
import { join } from "path";
import { readFileSync } from "fs";
import {
aiProviders,
clients,
db,
resourcePolicies,
@@ -113,6 +114,32 @@ export async function getUniqueResourceName(orgId: string): Promise<string> {
}
}
export async function getUniqueProviderName(orgId: string): Promise<string> {
let loops = 0;
while (true) {
if (loops > 100) {
throw new Error("Could not generate a unique name");
}
const name = generateName();
const aiProviderCount = await db
.select({
niceId: aiProviders.niceId,
orgId: aiProviders.orgId
})
.from(aiProviders)
.where(
and(eq(aiProviders.niceId, name), eq(aiProviders.orgId, orgId))
);
if (aiProviderCount.length === 0) {
return name;
}
loops++;
}
}
export async function getUniqueResourcePolicyName(
orgId: string
): Promise<string> {
+23
View File
@@ -1,3 +1,4 @@
import config from "@server/lib/config";
import { Pool, PoolConfig } from "pg";
export function createPoolConfig(
@@ -39,6 +40,28 @@ export function attachPoolErrorHandlers(pool: Pool, label: string): void {
`Failed to set statement_timeout on ${label} client: ${err.message}`
);
});
// Disable JIT compilation for this connection. Our hot-path queries
// (e.g. resource-by-domain lookups) join many tables but only ever
// return a handful of rows. When planner row estimates drift (e.g.
// due to autovacuum lag under write-heavy load), Postgres decides
// these plans are expensive enough to JIT-compile, which can add
// multiple seconds of pure compilation overhead per query and
// saturate the connection pool. JIT never pays off for these
// short-lived OLTP queries, so it's disabled outright rather than
// relying on statistics staying fresh.
//
// Set via a runtime SET command rather than the `options: "-c
// jit=off"` startup parameter: connections in SaaS mode go through
// a pooler (e.g. PgBouncer) that rejects arbitrary startup packet
// options with a protocol_violation (08P01) error.
if (config.getRawConfig().postgres?.pool.jit_mode == false) {
client.query("SET jit = off").catch((err: Error) => {
console.warn(
`Failed to set jit=off on ${label} client: ${err.message}`
);
});
}
});
}
+2 -21
View File
@@ -29,25 +29,6 @@ import {
labels
} from "./schema";
export const certificates = pgTable("certificates", {
certId: serial("certId").primaryKey(),
domain: varchar("domain", { length: 255 }).notNull().unique(),
domainId: varchar("domainId").references(() => domains.domainId, {
onDelete: "cascade"
}),
wildcard: boolean("wildcard").default(false),
status: varchar("status", { length: 50 }).notNull().default("pending"), // pending, requested, valid, expired, failed
expiresAt: bigint("expiresAt", { mode: "number" }),
lastRenewalAttempt: bigint("lastRenewalAttempt", { mode: "number" }),
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
updatedAt: bigint("updatedAt", { mode: "number" }).notNull(),
orderId: varchar("orderId", { length: 500 }),
errorMessage: text("errorMessage"),
renewalCount: integer("renewalCount").default(0),
certFile: text("certFile"),
keyFile: text("keyFile")
});
export const dnsChallenge = pgTable("dnsChallenges", {
dnsChallengeId: serial("dnsChallengeId").primaryKey(),
domain: varchar("domain", { length: 255 }).notNull(),
@@ -95,7 +76,8 @@ export const subscriptions = pgTable("subscriptions", {
billingCycleAnchor: bigint("billingCycleAnchor", { mode: "number" }),
expiresAt: bigint("expiresAt", { mode: "number" }),
trial: boolean("trial").default(false),
type: varchar("type", { length: 50 }) // tier1, tier2, tier3, or license
type: varchar("type", { length: 50 }), // tier1, tier2, tier3, or license
override: boolean("override").default(false)
});
export const subscriptionItems = pgTable("subscriptionItems", {
@@ -632,7 +614,6 @@ export const trialNotifications = pgTable("trialNotifications", {
export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>;
export type Certificate = InferSelectModel<typeof certificates>;
export type DnsChallenge = InferSelectModel<typeof dnsChallenge>;
export type Customer = InferSelectModel<typeof customers>;
export type Subscription = InferSelectModel<typeof subscriptions>;
+677 -93
View File
@@ -3,6 +3,7 @@ import { InferSelectModel, sql } from "drizzle-orm";
import {
bigint,
boolean,
check,
index,
integer,
pgTable,
@@ -11,6 +12,7 @@ import {
serial,
text,
unique,
uniqueIndex,
varchar
} from "drizzle-orm/pg-core";
@@ -18,14 +20,15 @@ export const domains = pgTable("domains", {
domainId: varchar("domainId").primaryKey(),
baseDomain: varchar("baseDomain").notNull(),
configManaged: boolean("configManaged").notNull().default(false),
type: varchar("type"), // "ns", "cname", "wildcard"
type: varchar("type").$type<"ns" | "cname" | "wildcard">(),
verified: boolean("verified").notNull().default(false),
failed: boolean("failed").notNull().default(false),
tries: integer("tries").notNull().default(0),
certResolver: varchar("certResolver"),
customCertResolver: varchar("customCertResolver"),
preferWildcardCert: boolean("preferWildcardCert"),
errorMessage: text("errorMessage")
errorMessage: text("errorMessage"),
lastCheckedAt: integer("lastCheckedAt")
});
export const dnsRecords = pgTable("dnsRecords", {
@@ -62,6 +65,11 @@ export const orgs = pgTable("orgs", {
) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
.notNull()
.default(0),
settingsLogRetentionDaysAISessions: integer(
"settingsLogRetentionDaysAISessions"
) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
.notNull()
.default(7),
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format)
isBillingOrg: boolean("isBillingOrg"),
@@ -97,7 +105,7 @@ export const sites = pgTable(
}),
name: varchar("name").notNull(),
pubKey: varchar("pubKey"),
subnet: varchar("subnet"),
exitNodeSubnet: text("exitNodeSubnet"), // this is the subnet when connecting to an exit node and INCLUDES THE CIDR
megabytesIn: real("bytesIn").default(0),
megabytesOut: real("bytesOut").default(0),
lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
@@ -106,6 +114,7 @@ export const sites = pgTable(
lastPing: integer("lastPing"),
address: varchar("address"),
endpoint: varchar("endpoint"),
localEndpoints: varchar("localEndpoints"), // JSON encoded list of string ips on the local machine to try to connect to
publicKey: varchar("publicKey"),
lastHolePunch: bigint("lastHolePunch", { mode: "number" }),
listenPort: integer("listenPort"),
@@ -128,7 +137,8 @@ export const sites = pgTable(
t.exitNodeId,
t.type,
t.siteId
)
),
index("idx_sites_orgid_niceid").on(t.orgId, t.niceId)
]
);
@@ -191,22 +201,67 @@ export const resources = pgTable(
postAuthPath: text("postAuthPath"),
health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown"
wildcard: boolean("wildcard").notNull().default(false),
mode: text("mode").default("http").notNull(), // rdp, ssh, http, vnc
mode: text("mode")
.default("http")
.$type<
"rdp" | "ssh" | "http" | "vnc" | "inference" | "tcp" | "udp"
>()
.notNull(),
pamMode: varchar("pamMode", { length: 32 })
.$type<"passthrough" | "push">()
.default("passthrough"),
authDaemonMode: varchar("authDaemonMode", { length: 32 })
.$type<"site" | "remote" | "native">()
.default("site"),
authDaemonPort: integer("authDaemonPort").default(22123)
authDaemonPort: integer("authDaemonPort").default(22123),
status: varchar("status")
.$type<"pending" | "approved">()
.default("approved")
},
(t) => [
index("idx_resources_fulldomain")
.on(t.fullDomain)
.where(sql`${t.fullDomain} IS NOT NULL`)
.where(sql`${t.fullDomain} IS NOT NULL`),
index("idx_resources_niceid").on(t.niceId),
index("idx_resources_orgid_niceid").on(t.orgId, t.niceId)
]
);
export const resourceAiProviders = pgTable(
"resourceAiProviders",
{
resourceId: integer("resourceId")
.notNull()
.references(() => resources.resourceId, { onDelete: "cascade" }),
providerId: integer("providerId")
.notNull()
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
accessMode: varchar("accessMode")
.$type<"inherit" | "select">()
.notNull()
.default("inherit"),
enabled: boolean("enabled").notNull().default(true)
},
(t) => [primaryKey({ columns: [t.resourceId, t.providerId] })]
);
export const resourceAiModels = pgTable(
"resourceAiModels",
{
resourceId: integer("resourceId")
.notNull()
.references(() => resources.resourceId, { onDelete: "cascade" }),
modelId: integer("modelId")
.notNull()
.references(() => aiModels.modelId, { onDelete: "cascade" }),
listType: varchar("listType")
.$type<"allow" | "block">()
.notNull()
.default("allow")
},
(t) => [primaryKey({ columns: [t.resourceId, t.modelId] })]
);
export const labels = pgTable("labels", {
labelId: serial("labelId").primaryKey(),
name: varchar("name").notNull(),
@@ -218,6 +273,21 @@ export const labels = pgTable("labels", {
.notNull()
});
export const launcherViews = pgTable("launcherViews", {
viewId: serial("viewId").primaryKey(),
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
userId: varchar("userId").references(() => users.userId, {
onDelete: "cascade"
}),
name: varchar("name").notNull(),
config: text("config").notNull(),
isDefault: boolean("isDefault").notNull().default(false),
createdAt: varchar("createdAt").notNull(),
updatedAt: varchar("updatedAt").notNull()
});
export const siteLabels = pgTable(
"siteLabels",
{
@@ -294,11 +364,18 @@ export const targets = pgTable(
"targets",
{
targetId: serial("targetId").primaryKey(),
resourceId: integer("resourceId")
.references(() => resources.resourceId, {
resourceId: integer("resourceId").references(
() => resources.resourceId,
{
onDelete: "cascade"
})
.notNull(),
}
),
providerId: integer("providerId").references(
() => aiProviders.providerId,
{
onDelete: "cascade"
}
),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
@@ -322,6 +399,7 @@ export const targets = pgTable(
},
(t) => [
index("idx_targets_resourceid_siteid").on(t.resourceId, t.siteId),
index("idx_targets_providerid_siteid").on(t.providerId, t.siteId),
index("idx_targets_site_enabled_priority_target_resource")
.on(t.siteId, t.priority.desc(), t.targetId, t.resourceId)
.where(sql`${t.enabled} = true`)
@@ -384,63 +462,122 @@ export const exitNodes = pgTable("exitNodes", {
region: varchar("region")
});
export const siteResources = pgTable("siteResources", {
// this is for the clients
siteResourceId: serial("siteResourceId").primaryKey(),
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
networkId: integer("networkId").references(() => networks.networkId, {
onDelete: "set null"
}),
defaultNetworkId: integer("defaultNetworkId").references(
() => networks.networkId,
{
onDelete: "restrict"
}
),
niceId: varchar("niceId").notNull(),
name: varchar("name").notNull(),
ssl: boolean("ssl").notNull().default(false),
mode: varchar("mode").$type<"host" | "cidr" | "http" | "ssh">().notNull(), // "host" | "cidr" | "http"
scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
proxyPort: integer("proxyPort"), // only for port mode
destinationPort: integer("destinationPort"), // only for port mode
destination: varchar("destination"), // ip, cidr, hostname; validate against the mode
enabled: boolean("enabled").notNull().default(true),
alias: varchar("alias"),
aliasAddress: varchar("aliasAddress"),
tcpPortRangeString: varchar("tcpPortRangeString").notNull().default("*"),
udpPortRangeString: varchar("udpPortRangeString").notNull().default("*"),
disableIcmp: boolean("disableIcmp").notNull().default(false),
authDaemonPort: integer("authDaemonPort").default(22123),
pamMode: varchar("pamMode", { length: 32 })
.$type<"passthrough" | "push">()
.default("passthrough"),
authDaemonMode: varchar("authDaemonMode", { length: 32 })
.$type<"site" | "remote" | "native">()
.default("site"),
domainId: varchar("domainId").references(() => domains.domainId, {
onDelete: "set null"
}),
subdomain: varchar("subdomain"),
fullDomain: varchar("fullDomain")
});
export const siteResources = pgTable(
"siteResources",
{
// this is for the clients
siteResourceId: serial("siteResourceId").primaryKey(),
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
networkId: integer("networkId").references(() => networks.networkId, {
onDelete: "set null"
}),
defaultNetworkId: integer("defaultNetworkId").references(
() => networks.networkId,
{
onDelete: "restrict"
}
),
requiresExitNodeConnection: boolean("requiresExitNodeConnection")
.notNull()
.default(false),
niceId: varchar("niceId").notNull(),
name: varchar("name").notNull(),
ssl: boolean("ssl").notNull().default(false),
mode: varchar("mode")
.$type<"host" | "cidr" | "http" | "ssh" | "inference">()
.notNull(), // "host" | "cidr" | "http"
scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
proxyPort: integer("proxyPort"), // only for port mode
destinationPort: integer("destinationPort"), // only for port mode
destination: varchar("destination"), // ip, cidr, hostname; validate against the mode
enabled: boolean("enabled").notNull().default(true),
alias: varchar("alias"),
aliasAddress: varchar("aliasAddress"),
tcpPortRangeString: varchar("tcpPortRangeString")
.notNull()
.default("*"),
udpPortRangeString: varchar("udpPortRangeString")
.notNull()
.default("*"),
disableIcmp: boolean("disableIcmp").notNull().default(false),
authDaemonPort: integer("authDaemonPort").default(22123),
pamMode: varchar("pamMode", { length: 32 })
.$type<"passthrough" | "push">()
.default("passthrough"),
authDaemonMode: varchar("authDaemonMode", { length: 32 })
.$type<"site" | "remote" | "native">()
.default("site"),
domainId: varchar("domainId").references(() => domains.domainId, {
onDelete: "set null"
}),
subdomain: varchar("subdomain"),
fullDomain: varchar("fullDomain"),
status: varchar("status")
.$type<"pending" | "approved">()
.default("approved")
},
(t) => [index("idx_siteresources_orgid_niceid").on(t.orgId, t.niceId)]
);
export const networks = pgTable("networks", {
networkId: serial("networkId").primaryKey(),
niceId: text("niceId"),
name: text("name"),
scope: varchar("scope")
.$type<"global" | "resource">()
.notNull()
.default("global"),
orgId: varchar("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull()
});
export const siteResourceAiProviders = pgTable(
"siteResourceAiProviders",
{
siteResourceId: integer("siteResourceId")
.notNull()
.references(() => siteResources.siteResourceId, {
onDelete: "cascade"
}),
providerId: integer("providerId")
.notNull()
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
accessMode: varchar("accessMode")
.$type<"inherit" | "select">()
.notNull()
.default("inherit"),
enabled: boolean("enabled").notNull().default(true)
},
(t) => [primaryKey({ columns: [t.siteResourceId, t.providerId] })]
);
export const siteResourceAiModels = pgTable(
"siteResourceAiModels",
{
siteResourceId: integer("siteResourceId")
.notNull()
.references(() => siteResources.siteResourceId, {
onDelete: "cascade"
}),
modelId: integer("modelId")
.notNull()
.references(() => aiModels.modelId, { onDelete: "cascade" }),
listType: varchar("listType")
.$type<"allow" | "block">()
.notNull()
.default("allow")
},
(t) => [primaryKey({ columns: [t.siteResourceId, t.modelId] })]
);
export const networks = pgTable(
"networks",
{
networkId: serial("networkId").primaryKey(),
niceId: text("niceId"),
name: text("name"),
scope: varchar("scope")
.$type<"global" | "resource">()
.notNull()
.default("global"),
orgId: varchar("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [index("idx_networks_orgid").on(t.orgId)]
);
export const siteNetworks = pgTable(
"siteNetworks",
@@ -625,7 +762,7 @@ export const roles = pgTable("roles", {
name: varchar("name").notNull(),
description: varchar("description"),
requireDeviceApproval: boolean("requireDeviceApproval").default(false),
sshSudoMode: varchar("sshSudoMode", { length: 32 }).default("none"), // "none" | "full" | "commands"
sshSudoMode: varchar("sshSudoMode", { length: 32 }).default("full"), // "none" | "full" | "commands"
sshSudoCommands: text("sshSudoCommands").default("[]"),
sshCreateHomeDir: boolean("sshCreateHomeDir").default(true),
sshUnixGroups: text("sshUnixGroups").default("[]")
@@ -866,12 +1003,16 @@ export const resourceAccessToken = pgTable("resourceAccessToken", {
resourceId: integer("resourceId")
.notNull()
.references(() => resources.resourceId, { onDelete: "cascade" }),
userId: varchar("userId").references(() => users.userId, {
onDelete: "cascade"
}),
path: varchar("path"),
tokenHash: varchar("tokenHash").notNull(),
sessionLength: bigint("sessionLength", { mode: "number" }).notNull(),
expiresAt: bigint("expiresAt", { mode: "number" }),
title: varchar("title"),
description: varchar("description"),
persistSession: boolean("persistSession").notNull().default(false),
createdAt: bigint("createdAt", { mode: "number" }).notNull()
});
@@ -966,7 +1107,17 @@ export const resourceRules = pgTable("resourceRules", {
enabled: boolean("enabled").notNull().default(true),
priority: integer("priority").notNull(),
action: varchar("action").notNull(), // ACCEPT, DROP, PASS
match: varchar("match").notNull(), // CIDR, PATH, IP
match: varchar("match")
.$type<
| "CIDR"
| "PATH"
| "IP"
| "COUNTRY"
| "COUNTRY_IS_NOT"
| "ASN"
| "REGION"
>()
.notNull(), // CIDR, PATH, IP
value: varchar("value").notNull()
});
@@ -981,33 +1132,45 @@ export const resourcePolicyRules = pgTable("resourcePolicyRules", {
priority: integer("priority").notNull(),
action: varchar("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(),
match: varchar("match")
.$type<"CIDR" | "PATH" | "IP" | "COUNTRY" | "ASN" | "REGION">()
.$type<
| "CIDR"
| "PATH"
| "IP"
| "COUNTRY"
| "COUNTRY_IS_NOT"
| "ASN"
| "REGION"
>()
.notNull(),
value: varchar("value").notNull()
});
export const resourcePolicies = pgTable("resourcePolicies", {
resourcePolicyId: serial("resourcePolicyId").primaryKey(),
sso: boolean("sso").notNull().default(true),
applyRules: boolean("applyRules").notNull().default(false),
scope: varchar("scope")
.$type<"global" | "resource">()
.notNull()
.default("global"),
emailWhitelistEnabled: boolean("emailWhitelistEnabled")
.notNull()
.default(false),
idpId: integer("idpId").references(() => idp.idpId, {
onDelete: "set null"
}),
niceId: text("niceId").notNull(),
name: varchar("name").notNull(),
orgId: varchar("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull()
});
export const resourcePolicies = pgTable(
"resourcePolicies",
{
resourcePolicyId: serial("resourcePolicyId").primaryKey(),
sso: boolean("sso").notNull().default(true),
applyRules: boolean("applyRules").notNull().default(false),
scope: varchar("scope")
.$type<"global" | "resource">()
.notNull()
.default("global"),
emailWhitelistEnabled: boolean("emailWhitelistEnabled")
.notNull()
.default(false),
idpId: integer("idpId").references(() => idp.idpId, {
onDelete: "set null"
}),
niceId: text("niceId").notNull(),
name: varchar("name").notNull(),
orgId: varchar("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [index("idx_resourcepolicies_orgid_niceid").on(t.orgId, t.niceId)]
);
export const supporterKey = pgTable("supporterKey", {
keyId: serial("keyId").primaryKey(),
@@ -1084,6 +1247,52 @@ export const apiKeyOrg = pgTable("apiKeyOrg", {
.notNull()
});
export const virtualApiKeys = pgTable(
"virtualApiKeys",
{
virtualApiKeyId: varchar("virtualApiKeyId").primaryKey(),
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
kind: varchar("kind").$type<"user" | "manual">().notNull(),
userId: varchar("userId").references(() => users.userId, {
onDelete: "cascade"
}),
name: varchar("name"),
description: varchar("description"),
token: varchar("token").notNull(),
lastChars: varchar("lastChars").notNull(),
allResources: boolean("allResources").notNull().default(false),
expiresAt: bigint("expiresAt", { mode: "number" }),
lastUsedAt: bigint("lastUsedAt", { mode: "number" }),
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
createdByUserId: varchar("createdByUserId").references(
() => users.userId,
{ onDelete: "set null" }
)
},
(t) => [
uniqueIndex("virtual_api_key_user_identity_uniq")
.on(t.orgId, t.userId)
.where(sql`${t.kind} = 'user'`)
]
);
export const virtualApiKeyResources = pgTable(
"virtualApiKeyResources",
{
virtualApiKeyId: varchar("virtualApiKeyId")
.notNull()
.references(() => virtualApiKeys.virtualApiKeyId, {
onDelete: "cascade"
}),
resourceId: integer("resourceId")
.notNull()
.references(() => resources.resourceId, { onDelete: "cascade" })
},
(t) => [primaryKey({ columns: [t.virtualApiKeyId, t.resourceId] })]
);
export const idpOrg = pgTable("idpOrg", {
idpId: integer("idpId")
.notNull()
@@ -1116,6 +1325,7 @@ export const clients = pgTable(
name: varchar("name").notNull(),
pubKey: varchar("pubKey"),
subnet: varchar("subnet").notNull(),
exitNodeSubnet: varchar("exitNodeSubnet"), // INCLUDES THE CIDR
megabytesIn: real("bytesIn"),
megabytesOut: real("bytesOut"),
lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
@@ -1131,7 +1341,10 @@ export const clients = pgTable(
"pending" | "approved" | "denied"
>()
},
(t) => [index("idx_clients_userid").on(t.userId)]
(t) => [
index("idx_clients_userid").on(t.userId),
index("idx_clients_orgid_niceid").on(t.orgId, t.niceId)
]
);
export const clientSitesAssociationsCache = pgTable(
@@ -1471,6 +1684,358 @@ export const statusHistory = pgTable(
]
);
export const aiProviders = pgTable(
"aiProviders",
{
providerId: serial("providerId").primaryKey(),
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
name: varchar("name").notNull(),
niceId: varchar("niceId").notNull(),
type: varchar("type")
.$type<
| "openai"
| "anthropic"
| "googleGemini"
| "vertexAi"
| "bedrock"
| "microsoftFoundry"
| "openRouter"
| "vercelAiGateway"
| "custom"
>()
.notNull(),
upstreamUrl: text("upstreamUrl"),
apiKey: text("apiKey"),
apiKeyLastChars: varchar("apiKeyLastChars"),
authType: varchar("authType")
.$type<
| "bearer"
| "x-api-key"
| "x-goog-api-key"
| "hec"
| "cf-aig-authorization"
| "none"
| "passthrough"
>()
.notNull(),
routingMode: varchar("routingMode")
.$type<"url" | "target">()
.notNull()
.default("url"),
capabilities: text("capabilities").notNull().default("[]"),
headers: text("headers"), // JSON array of { name, value }
skipTlsVerification: boolean("skipTlsVerification")
.notNull()
.default(false),
enabled: boolean("enabled").notNull().default(true),
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
updatedAt: bigint("updatedAt", { mode: "number" }).notNull()
},
(t) => [index("idx_aiProviders_orgId_niceId").on(t.orgId, t.niceId)]
);
export const aiModels = pgTable(
"aiModels",
{
modelId: serial("modelId").primaryKey(),
providerId: integer("providerId")
.notNull()
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
modelKey: varchar("modelKey").notNull(),
name: varchar("name").notNull(),
listType: varchar("listType")
.$type<"allow" | "block">()
.notNull()
.default("allow"),
enabled: boolean("enabled").notNull().default(true),
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
updatedAt: bigint("updatedAt", { mode: "number" }).notNull()
},
(t) => [unique("ai_model_provider_key_uniq").on(t.providerId, t.modelKey)]
);
export const aiBudgets = pgTable(
"aiBudgets",
{
budgetId: serial("budgetId").primaryKey(),
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
providerId: integer("providerId").references(
() => aiProviders.providerId,
{ onDelete: "cascade" }
),
modelId: integer("modelId").references(() => aiModels.modelId, {
onDelete: "cascade"
}),
resourceId: integer("resourceId").references(
() => resources.resourceId,
{ onDelete: "cascade" }
),
siteResourceId: integer("siteResourceId").references(
() => siteResources.siteResourceId,
{ onDelete: "cascade" }
),
roleId: integer("roleId").references(() => roles.roleId, {
onDelete: "cascade"
}),
virtualApiKeyId: varchar("virtualApiKeyId").references(
() => virtualApiKeys.virtualApiKeyId,
{ onDelete: "cascade" }
),
amount: real("amount").notNull(),
unit: varchar("unit").$type<"usd" | "tokens">().notNull(),
period: varchar("period")
.$type<
| "monthly"
| "yearly"
| "lifetime"
| "daily"
| "hourly"
| "weekly"
>()
.notNull()
.default("monthly"),
enforcement: varchar("enforcement")
.$type<"hard" | "soft">()
.notNull()
.default("hard"),
enabled: boolean("enabled").notNull().default(true),
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
updatedAt: bigint("updatedAt", { mode: "number" }).notNull()
},
(t) => [
unique("ai_budget_provider_uniq").on(t.providerId, t.unit, t.period),
unique("ai_budget_model_uniq").on(t.modelId, t.unit, t.period),
unique("ai_budget_resource_uniq").on(t.resourceId, t.unit, t.period),
unique("ai_budget_site_resource_uniq").on(
t.siteResourceId,
t.unit,
t.period
),
unique("ai_budget_role_uniq").on(t.roleId, t.unit, t.period),
unique("ai_budget_virtual_api_key_uniq").on(
t.virtualApiKeyId,
t.unit,
t.period
)
]
);
export const aiUsageRecords = pgTable(
"aiUsageRecords",
{
id: serial("id").primaryKey(),
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
providerId: integer("providerId").references(
() => aiProviders.providerId,
{ onDelete: "set null" }
),
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" }
),
// Links this usage record back to the aiSessionLog row for the same
// request (aiSessionLog.sessionId), so token/cost usage can be shown
// alongside the session transcript. Not a DB-level FK - aiSessionLog
// lives in the separate logs database. Nullable because the session
// log may be disabled (retention set to 0) while usage tracking
// stays on.
sessionId: varchar("sessionId"),
requestedModel: varchar("requestedModel").notNull(),
promptTokens: integer("promptTokens").notNull().default(0),
cacheReadTokens: integer("cacheReadTokens").notNull().default(0),
cacheWriteTokens: integer("cacheWriteTokens").notNull().default(0),
completionTokens: integer("completionTokens").notNull().default(0),
reasoningTokens: integer("reasoningTokens").notNull().default(0),
totalTokens: integer("totalTokens").notNull().default(0),
costUsd: real("costUsd"),
estimated: boolean("estimated").notNull().default(false),
createdAt: bigint("createdAt", { mode: "number" }).notNull()
},
(t) => [
index("idx_ai_usage_records_org_provider_created").on(
t.orgId,
t.providerId,
t.createdAt
),
index("idx_ai_usage_records_org_resource_created").on(
t.orgId,
t.resourceId,
t.createdAt
),
index("idx_ai_usage_records_org_site_resource_created").on(
t.orgId,
t.siteResourceId,
t.createdAt
),
index("idx_ai_usage_records_org_user_created").on(
t.orgId,
t.userId,
t.createdAt
),
index("idx_ai_usage_records_org_virtual_api_key_created").on(
t.orgId,
t.virtualApiKeyId,
t.createdAt
),
index("idx_ai_usage_records_session").on(t.sessionId)
]
);
export const aiBudgetBreachEvents = pgTable(
"aiBudgetBreachEvents",
{
id: serial("id").primaryKey(),
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
budgetId: integer("budgetId")
.notNull()
.references(() => aiBudgets.budgetId, { onDelete: "cascade" }),
enforcement: varchar("enforcement").$type<"hard" | "soft">().notNull(),
unit: varchar("unit").$type<"usd" | "tokens">().notNull(),
period: varchar("period")
.$type<
| "monthly"
| "yearly"
| "lifetime"
| "daily"
| "hourly"
| "weekly"
>()
.notNull(),
amount: real("amount").notNull(),
usageAmount: real("usageAmount").notNull(),
blocked: boolean("blocked").notNull(),
requestUserId: varchar("requestUserId").references(() => users.userId, {
onDelete: "set null"
}),
createdAt: bigint("createdAt", { mode: "number" }).notNull()
},
(t) => [
index("idx_ai_budget_breach_events_budget_created").on(
t.budgetId,
t.createdAt
)
]
);
// 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 ms
},
(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(),
domainId: varchar("domainId").references(() => domains.domainId, {
onDelete: "cascade"
}),
wildcard: boolean("wildcard").default(false),
status: varchar("status", { length: 50 }).notNull().default("pending"), // pending, requested, valid, expired, failed
expiresAt: bigint("expiresAt", { mode: "number" }),
lastRenewalAttempt: bigint("lastRenewalAttempt", { mode: "number" }),
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
updatedAt: bigint("updatedAt", { mode: "number" }).notNull(),
orderId: varchar("orderId", { length: 500 }),
errorMessage: text("errorMessage"),
renewalCount: integer("renewalCount").default(0),
certFile: text("certFile"),
keyFile: text("keyFile")
});
export type Org = InferSelectModel<typeof orgs>;
export type User = InferSelectModel<typeof users>;
export type Site = InferSelectModel<typeof sites>;
@@ -1526,6 +2091,10 @@ export type Idp = InferSelectModel<typeof idp>;
export type ApiKey = InferSelectModel<typeof apiKeys>;
export type ApiKeyAction = InferSelectModel<typeof apiKeyActions>;
export type ApiKeyOrg = InferSelectModel<typeof apiKeyOrg>;
export type VirtualApiKey = InferSelectModel<typeof virtualApiKeys>;
export type VirtualApiKeyResource = InferSelectModel<
typeof virtualApiKeyResources
>;
export type Client = InferSelectModel<typeof clients>;
export type ClientSite = InferSelectModel<typeof clientSitesAssociationsCache>;
export type Olm = InferSelectModel<typeof olms>;
@@ -1550,6 +2119,21 @@ export type RoundTripMessageTracker = InferSelectModel<
export type Network = InferSelectModel<typeof networks>;
export type StatusHistory = InferSelectModel<typeof statusHistory>;
export type Label = InferSelectModel<typeof labels>;
export type LauncherView = InferSelectModel<typeof launcherViews>;
export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
export type UserPolicy = InferSelectModel<typeof userPolicies>;
export type ResourcePolicyRule = InferSelectModel<typeof resourcePolicyRules>;
export type AiProvider = InferSelectModel<typeof aiProviders>;
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
>;
export type ResourceAiModel = InferSelectModel<typeof resourceAiModels>;
export type SiteResourceAiModel = InferSelectModel<typeof siteResourceAiModels>;
export type Certificate = InferSelectModel<typeof certificates>;
+33 -1
View File
@@ -33,7 +33,9 @@ import {
resourcePolicyPassword,
ResourcePolicyPassword,
resourcePolicyHeaderAuth,
ResourcePolicyHeaderAuth
ResourcePolicyHeaderAuth,
resourceWhitelist,
resourcePolicyWhiteList
} from "@server/db";
import { alias } from "@server/db";
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
@@ -448,6 +450,36 @@ export async function getResourceRules(
return [...directRules, ...offsetPolicyRules] as ResourceRule[];
}
/**
* Get the whitelisted email associated with a resource session's whitelist
* match (either a direct resource whitelist entry or a resource policy
* whitelist entry).
*/
export async function getWhitelistEmail(
whitelistId?: number | null,
policyWhitelistId?: number | null
): Promise<string | null> {
if (whitelistId) {
const [row] = await db
.select({ email: resourceWhitelist.email })
.from(resourceWhitelist)
.where(eq(resourceWhitelist.whitelistId, whitelistId))
.limit(1);
return row?.email ?? null;
}
if (policyWhitelistId) {
const [row] = await db
.select({ email: resourcePolicyWhiteList.email })
.from(resourcePolicyWhiteList)
.where(eq(resourcePolicyWhiteList.whitelistId, policyWhitelistId))
.limit(1);
return row?.email ?? null;
}
return null;
}
/**
* Get organization login page
*/
+6 -1
View File
@@ -5,6 +5,7 @@ import path from "path";
import fs from "fs";
import { APP_PATH } from "@server/lib/consts";
import { existsSync, mkdirSync } from "fs";
import logger from "@server/logger";
export const location = path.join(APP_PATH, "db", "db.sqlite");
export const exists = checkFileExists(location);
@@ -12,7 +13,11 @@ export const exists = checkFileExists(location);
bootstrapVolume();
function createDb() {
const sqlite = new Database(location);
const verbose =
process.env.QUERY_LOGGING == "true"
? (message: unknown) => logger.debug(String(message))
: undefined;
const sqlite = new Database(location, { verbose });
if (process.env.ENABLE_SQLITE_WAL_MODE == "true") {
// Enable WAL mode — allows concurrent readers + single writer, preventing
+2 -21
View File
@@ -23,25 +23,6 @@ import {
users
} from "./schema";
export const certificates = sqliteTable("certificates", {
certId: integer("certId").primaryKey({ autoIncrement: true }),
domain: text("domain").notNull().unique(),
domainId: text("domainId").references(() => domains.domainId, {
onDelete: "cascade"
}),
wildcard: integer("wildcard", { mode: "boolean" }).default(false),
status: text("status").notNull().default("pending"), // pending, requested, valid, expired, failed
expiresAt: integer("expiresAt"),
lastRenewalAttempt: integer("lastRenewalAttempt"),
createdAt: integer("createdAt").notNull(),
updatedAt: integer("updatedAt").notNull(),
orderId: text("orderId"),
errorMessage: text("errorMessage"),
renewalCount: integer("renewalCount").default(0),
certFile: text("certFile"),
keyFile: text("keyFile")
});
export const dnsChallenge = sqliteTable("dnsChallenges", {
dnsChallengeId: integer("dnsChallengeId").primaryKey({
autoIncrement: true
@@ -89,7 +70,8 @@ export const subscriptions = sqliteTable("subscriptions", {
expiresAt: integer("expiresAt"),
trial: integer("trial", { mode: "boolean" }).default(false),
billingCycleAnchor: integer("billingCycleAnchor"),
type: text("type") // tier1, tier2, tier3, or license
type: text("type"), // tier1, tier2, tier3, or license
override: integer("override", { mode: "boolean" }).default(false)
});
export const subscriptionItems = sqliteTable("subscriptionItems", {
@@ -627,7 +609,6 @@ export const trialNotifications = sqliteTable("trialNotifications", {
export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>;
export type Certificate = InferSelectModel<typeof certificates>;
export type DnsChallenge = InferSelectModel<typeof dnsChallenge>;
export type Customer = InferSelectModel<typeof customers>;
export type Subscription = InferSelectModel<typeof subscriptions>;
+587 -17
View File
@@ -1,12 +1,15 @@
import { randomUUID } from "crypto";
import { InferSelectModel } from "drizzle-orm";
import { InferSelectModel, sql } from "drizzle-orm";
import {
check,
index,
integer,
primaryKey,
real,
sqliteTable,
text,
unique
unique,
uniqueIndex
} from "drizzle-orm/sqlite-core";
export const domains = sqliteTable("domains", {
@@ -15,13 +18,15 @@ export const domains = sqliteTable("domains", {
configManaged: integer("configManaged", { mode: "boolean" })
.notNull()
.default(false),
type: text("type"), // "ns", "cname", "wildcard"
type: text("type").$type<"ns" | "cname" | "wildcard">(),
verified: integer("verified", { mode: "boolean" }).notNull().default(false),
failed: integer("failed", { mode: "boolean" }).notNull().default(false),
tries: integer("tries").notNull().default(0),
certResolver: text("certResolver"),
customCertResolver: text("customCertResolver"),
preferWildcardCert: integer("preferWildcardCert", { mode: "boolean" }),
errorMessage: text("errorMessage")
errorMessage: text("errorMessage"),
lastCheckedAt: integer("lastCheckedAt")
});
export const dnsRecords = sqliteTable("dnsRecords", {
@@ -59,6 +64,11 @@ export const orgs = sqliteTable("orgs", {
) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
.notNull()
.default(0),
settingsLogRetentionDaysAISessions: integer(
"settingsLogRetentionDaysAISessions"
) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
.notNull()
.default(7),
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format)
isBillingOrg: integer("isBillingOrg", { mode: "boolean" }),
@@ -105,7 +115,7 @@ export const sites = sqliteTable("sites", {
}),
name: text("name").notNull(),
pubKey: text("pubKey"),
subnet: text("subnet"),
exitNodeSubnet: text("exitNodeSubnet"),
megabytesIn: integer("bytesIn").default(0),
megabytesOut: integer("bytesOut").default(0),
lastBandwidthUpdate: text("lastBandwidthUpdate"),
@@ -116,6 +126,7 @@ export const sites = sqliteTable("sites", {
// exit node stuff that is how to connect to the site when it has a wg server
address: text("address"), // this is the address of the wireguard interface in newt
endpoint: text("endpoint"), // this is how to reach gerbil externally - gets put into the wireguard config
localEndpoints: text("localEndpoints"), // JSON encoded list of string ips on the local machine to try to connect to
publicKey: text("publicKey"), // TODO: Fix typo in publicKey
lastHolePunch: integer("lastHolePunch"),
listenPort: integer("listenPort"),
@@ -200,16 +211,55 @@ export const resources = sqliteTable("resources", {
postAuthPath: text("postAuthPath"),
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown"
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false),
mode: text("mode").default("http").notNull(), // rdp, ssh, http, vnc
mode: text("mode")
.default("http")
.$type<"rdp" | "ssh" | "http" | "vnc" | "inference" | "tcp" | "udp">()
.notNull(), // rdp, ssh, http, vnc, inference
pamMode: text("pamMode")
.$type<"passthrough" | "push">()
.default("passthrough"),
authDaemonMode: text("authDaemonMode")
.$type<"site" | "remote" | "native">()
.default("site"),
authDaemonPort: integer("authDaemonPort").default(22123)
authDaemonPort: integer("authDaemonPort").default(22123),
status: text("status").$type<"pending" | "approved">().default("approved")
});
export const resourceAiProviders = sqliteTable(
"resourceAiProviders",
{
resourceId: integer("resourceId")
.notNull()
.references(() => resources.resourceId, { onDelete: "cascade" }),
providerId: integer("providerId")
.notNull()
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
accessMode: text("accessMode")
.$type<"inherit" | "select">()
.notNull()
.default("inherit"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true)
},
(t) => [primaryKey({ columns: [t.resourceId, t.providerId] })]
);
export const resourceAiModels = sqliteTable(
"resourceAiModels",
{
resourceId: integer("resourceId")
.notNull()
.references(() => resources.resourceId, { onDelete: "cascade" }),
modelId: integer("modelId")
.notNull()
.references(() => aiModels.modelId, { onDelete: "cascade" }),
listType: text("listType")
.$type<"allow" | "block">()
.notNull()
.default("allow")
},
(t) => [primaryKey({ columns: [t.resourceId, t.modelId] })]
);
export const labels = sqliteTable("labels", {
labelId: integer("labelId").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
@@ -221,6 +271,23 @@ export const labels = sqliteTable("labels", {
.notNull()
});
export const launcherViews = sqliteTable("launcherViews", {
viewId: integer("viewId").primaryKey({ autoIncrement: true }),
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
userId: text("userId").references(() => users.userId, {
onDelete: "cascade"
}),
name: text("name").notNull(),
config: text("config").notNull(),
isDefault: integer("isDefault", { mode: "boolean" })
.notNull()
.default(false),
createdAt: text("createdAt").notNull(),
updatedAt: text("updatedAt").notNull()
});
export const siteLabels = sqliteTable(
"siteLabels",
{
@@ -301,11 +368,12 @@ export const clientLabels = sqliteTable(
export const targets = sqliteTable("targets", {
targetId: integer("targetId").primaryKey({ autoIncrement: true }),
resourceId: integer("resourceId")
.references(() => resources.resourceId, {
onDelete: "cascade"
})
.notNull(),
resourceId: integer("resourceId").references(() => resources.resourceId, {
onDelete: "cascade"
}),
providerId: integer("providerId").references(() => aiProviders.providerId, {
onDelete: "cascade"
}),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
@@ -401,10 +469,17 @@ export const siteResources = sqliteTable("siteResources", {
() => networks.networkId,
{ onDelete: "restrict" }
),
requiresExitNodeConnection: integer("requiresExitNodeConnection", {
mode: "boolean"
})
.notNull()
.default(false),
niceId: text("niceId").notNull(),
name: text("name").notNull(),
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
mode: text("mode").$type<"host" | "cidr" | "http" | "ssh">().notNull(), // "host" | "cidr" | "http"
mode: text("mode")
.$type<"host" | "cidr" | "http" | "ssh" | "inference">()
.notNull(), // "host" | "cidr" | "http"
scheme: text("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
proxyPort: integer("proxyPort"), // only for port mode
destinationPort: integer("destinationPort"), // only for port mode
@@ -428,9 +503,49 @@ export const siteResources = sqliteTable("siteResources", {
onDelete: "set null"
}),
subdomain: text("subdomain"),
fullDomain: text("fullDomain")
fullDomain: text("fullDomain"),
status: text("status").$type<"pending" | "approved">().default("approved")
});
export const siteResourceAiProviders = sqliteTable(
"siteResourceAiProviders",
{
siteResourceId: integer("siteResourceId")
.notNull()
.references(() => siteResources.siteResourceId, {
onDelete: "cascade"
}),
providerId: integer("providerId")
.notNull()
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
accessMode: text("accessMode")
.$type<"inherit" | "select">()
.notNull()
.default("inherit"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true)
},
(t) => [primaryKey({ columns: [t.siteResourceId, t.providerId] })]
);
export const siteResourceAiModels = sqliteTable(
"siteResourceAiModels",
{
siteResourceId: integer("siteResourceId")
.notNull()
.references(() => siteResources.siteResourceId, {
onDelete: "cascade"
}),
modelId: integer("modelId")
.notNull()
.references(() => aiModels.modelId, { onDelete: "cascade" }),
listType: text("listType")
.$type<"allow" | "block">()
.notNull()
.default("allow")
},
(t) => [primaryKey({ columns: [t.siteResourceId, t.modelId] })]
);
export const networks = sqliteTable("networks", {
networkId: integer("networkId").primaryKey({ autoIncrement: true }),
niceId: text("niceId"),
@@ -577,6 +692,7 @@ export const clients = sqliteTable("clients", {
pubKey: text("pubKey"),
olmId: text("olmId"), // to lock it to a specific olm optionally
subnet: text("subnet").notNull(),
exitNodeSubnet: text("exitNodeSubnet"), // this is the subnet when connecting to an exit node
megabytesIn: integer("bytesIn"),
megabytesOut: integer("bytesOut"),
lastBandwidthUpdate: text("lastBandwidthUpdate"),
@@ -879,7 +995,7 @@ export const roles = sqliteTable("roles", {
requireDeviceApproval: integer("requireDeviceApproval", {
mode: "boolean"
}).default(false),
sshSudoMode: text("sshSudoMode").default("none"), // "none" | "full" | "commands"
sshSudoMode: text("sshSudoMode").default("full"), // "none" | "full" | "commands"
sshSudoCommands: text("sshSudoCommands").default("[]"),
sshCreateHomeDir: integer("sshCreateHomeDir", { mode: "boolean" }).default(
true
@@ -1104,12 +1220,18 @@ export const resourceAccessToken = sqliteTable("resourceAccessToken", {
resourceId: integer("resourceId")
.notNull()
.references(() => resources.resourceId, { onDelete: "cascade" }),
userId: text("userId").references(() => users.userId, {
onDelete: "cascade"
}),
path: text("path"),
tokenHash: text("tokenHash").notNull(),
sessionLength: integer("sessionLength").notNull(),
expiresAt: integer("expiresAt"),
title: text("title"),
description: text("description"),
persistSession: integer("persistSession", { mode: "boolean" })
.notNull()
.default(false),
createdAt: integer("createdAt").notNull()
});
@@ -1205,7 +1327,17 @@ export const resourceRules = sqliteTable("resourceRules", {
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
priority: integer("priority").notNull(),
action: text("action").notNull(), // ACCEPT, DROP, PASS
match: text("match").notNull(), // CIDR, PATH, IP
match: text("match")
.$type<
| "CIDR"
| "PATH"
| "IP"
| "COUNTRY"
| "COUNTRY_IS_NOT"
| "ASN"
| "REGION"
>()
.notNull(), // CIDR, PATH, IP
value: text("value").notNull()
});
@@ -1252,7 +1384,15 @@ export const resourcePolicyRules = sqliteTable("resourcePolicyRules", {
priority: integer("priority").notNull(),
action: text("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(),
match: text("match")
.$type<"CIDR" | "PATH" | "IP" | "COUNTRY" | "ASN" | "REGION">()
.$type<
| "CIDR"
| "PATH"
| "IP"
| "COUNTRY"
| "COUNTRY_IS_NOT"
| "ASN"
| "REGION"
>()
.notNull(),
value: text("value").notNull()
});
@@ -1365,6 +1505,54 @@ export const apiKeyOrg = sqliteTable("apiKeyOrg", {
.notNull()
});
export const virtualApiKeys = sqliteTable(
"virtualApiKeys",
{
virtualApiKeyId: text("virtualApiKeyId").primaryKey(),
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
kind: text("kind").$type<"user" | "manual">().notNull(),
userId: text("userId").references(() => users.userId, {
onDelete: "cascade"
}),
name: text("name"),
description: text("description"),
token: text("token").notNull(),
lastChars: text("lastChars").notNull(),
allResources: integer("allResources", { mode: "boolean" })
.notNull()
.default(false),
expiresAt: integer("expiresAt"),
lastUsedAt: integer("lastUsedAt"),
createdAt: integer("createdAt").notNull(),
createdByUserId: text("createdByUserId").references(
() => users.userId,
{ onDelete: "set null" }
)
},
(t) => [
uniqueIndex("virtual_api_key_user_identity_uniq")
.on(t.orgId, t.userId)
.where(sql`${t.kind} = 'user'`)
]
);
export const virtualApiKeyResources = sqliteTable(
"virtualApiKeyResources",
{
virtualApiKeyId: text("virtualApiKeyId")
.notNull()
.references(() => virtualApiKeys.virtualApiKeyId, {
onDelete: "cascade"
}),
resourceId: integer("resourceId")
.notNull()
.references(() => resources.resourceId, { onDelete: "cascade" })
},
(t) => [primaryKey({ columns: [t.virtualApiKeyId, t.resourceId] })]
);
export const idpOrg = sqliteTable("idpOrg", {
idpId: integer("idpId")
.notNull()
@@ -1480,6 +1668,370 @@ export const statusHistory = sqliteTable(
]
);
export const aiProviders = sqliteTable(
"aiProviders",
{
providerId: integer("providerId").primaryKey({ autoIncrement: true }),
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
name: text("name").notNull(),
niceId: text("niceId").notNull(),
type: text("type")
.$type<
| "openai"
| "anthropic"
| "googleGemini"
| "vertexAi"
| "bedrock"
| "microsoftFoundry"
| "openRouter"
| "vercelAiGateway"
| "custom"
>()
.notNull(),
upstreamUrl: text("upstreamUrl"),
apiKey: text("apiKey"),
apiKeyLastChars: text("apiKeyLastChars"),
authType: text("authType")
.$type<
| "bearer"
| "x-api-key"
| "x-goog-api-key"
| "hec"
| "cf-aig-authorization"
| "none"
| "passthrough"
>()
.notNull(),
routingMode: text("routingMode")
.$type<"url" | "target">()
.notNull()
.default("url"),
capabilities: text("capabilities").notNull().default("[]"),
headers: text("headers"), // JSON array of { name, value }
skipTlsVerification: integer("skipTlsVerification", { mode: "boolean" })
.notNull()
.default(false),
enabled: integer("enabled", { mode: "boolean" })
.notNull()
.default(true),
createdAt: integer("createdAt").notNull(),
updatedAt: integer("updatedAt").notNull()
},
(t) => [index("idx_aiProviders_orgId_niceId").on(t.orgId, t.niceId)]
);
export const aiModels = sqliteTable(
"aiModels",
{
modelId: integer("modelId").primaryKey({ autoIncrement: true }),
providerId: integer("providerId")
.notNull()
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
modelKey: text("modelKey").notNull(),
name: text("name").notNull(),
listType: text("listType")
.$type<"allow" | "block">()
.notNull()
.default("allow"),
enabled: integer("enabled", { mode: "boolean" })
.notNull()
.default(true),
createdAt: integer("createdAt").notNull(),
updatedAt: integer("updatedAt").notNull()
},
(t) => [unique("ai_model_provider_key_uniq").on(t.providerId, t.modelKey)]
);
export const aiBudgets = sqliteTable(
"aiBudgets",
{
budgetId: integer("budgetId").primaryKey({ autoIncrement: true }),
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
providerId: integer("providerId").references(
() => aiProviders.providerId,
{ onDelete: "cascade" }
),
modelId: integer("modelId").references(() => aiModels.modelId, {
onDelete: "cascade"
}),
resourceId: integer("resourceId").references(
() => resources.resourceId,
{ onDelete: "cascade" }
),
siteResourceId: integer("siteResourceId").references(
() => siteResources.siteResourceId,
{ onDelete: "cascade" }
),
roleId: integer("roleId").references(() => roles.roleId, {
onDelete: "cascade"
}),
virtualApiKeyId: text("virtualApiKeyId").references(
() => virtualApiKeys.virtualApiKeyId,
{ onDelete: "cascade" }
),
amount: real("amount").notNull(),
unit: text("unit").$type<"usd" | "tokens">().notNull(),
period: text("period")
.$type<
| "monthly"
| "yearly"
| "lifetime"
| "daily"
| "hourly"
| "weekly"
>()
.notNull()
.default("monthly"),
enforcement: text("enforcement")
.$type<"hard" | "soft">()
.notNull()
.default("hard"),
enabled: integer("enabled", { mode: "boolean" })
.notNull()
.default(true),
createdAt: integer("createdAt").notNull(),
updatedAt: integer("updatedAt").notNull()
},
(t) => [
unique("ai_budget_provider_uniq").on(t.providerId, t.unit, t.period),
unique("ai_budget_model_uniq").on(t.modelId, t.unit, t.period),
unique("ai_budget_resource_uniq").on(t.resourceId, t.unit, t.period),
unique("ai_budget_site_resource_uniq").on(
t.siteResourceId,
t.unit,
t.period
),
unique("ai_budget_role_uniq").on(t.roleId, t.unit, t.period),
unique("ai_budget_virtual_api_key_uniq").on(
t.virtualApiKeyId,
t.unit,
t.period
)
]
);
export const aiUsageRecords = sqliteTable(
"aiUsageRecords",
{
id: integer("id").primaryKey({ autoIncrement: true }),
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
providerId: integer("providerId").references(
() => aiProviders.providerId,
{ onDelete: "set null" }
),
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" }
),
// Links this usage record back to the aiSessionLog row for the same
// request (aiSessionLog.sessionId), so token/cost usage can be shown
// alongside the session transcript. Not a DB-level FK - aiSessionLog
// lives in the separate logs database. Nullable because the session
// log may be disabled (retention set to 0) while usage tracking
// stays on.
sessionId: text("sessionId"),
requestedModel: text("requestedModel").notNull(),
promptTokens: integer("promptTokens").notNull().default(0),
cacheReadTokens: integer("cacheReadTokens").notNull().default(0),
cacheWriteTokens: integer("cacheWriteTokens").notNull().default(0),
completionTokens: integer("completionTokens").notNull().default(0),
reasoningTokens: integer("reasoningTokens").notNull().default(0),
totalTokens: integer("totalTokens").notNull().default(0),
costUsd: real("costUsd"),
estimated: integer("estimated", { mode: "boolean" })
.notNull()
.default(false),
createdAt: integer("createdAt").notNull()
},
(t) => [
index("idx_ai_usage_records_org_provider_created").on(
t.orgId,
t.providerId,
t.createdAt
),
index("idx_ai_usage_records_org_resource_created").on(
t.orgId,
t.resourceId,
t.createdAt
),
index("idx_ai_usage_records_org_site_resource_created").on(
t.orgId,
t.siteResourceId,
t.createdAt
),
index("idx_ai_usage_records_org_user_created").on(
t.orgId,
t.userId,
t.createdAt
),
index("idx_ai_usage_records_org_virtual_api_key_created").on(
t.orgId,
t.virtualApiKeyId,
t.createdAt
),
index("idx_ai_usage_records_session").on(t.sessionId)
]
);
export const aiBudgetBreachEvents = sqliteTable(
"aiBudgetBreachEvents",
{
id: integer("id").primaryKey({ autoIncrement: true }),
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
budgetId: integer("budgetId")
.notNull()
.references(() => aiBudgets.budgetId, { onDelete: "cascade" }),
enforcement: text("enforcement").$type<"hard" | "soft">().notNull(),
unit: text("unit").$type<"usd" | "tokens">().notNull(),
period: text("period")
.$type<
| "monthly"
| "yearly"
| "lifetime"
| "daily"
| "hourly"
| "weekly"
>()
.notNull(),
amount: real("amount").notNull(),
usageAmount: real("usageAmount").notNull(),
blocked: integer("blocked", { mode: "boolean" }).notNull(),
requestUserId: text("requestUserId").references(() => users.userId, {
onDelete: "set null"
}),
createdAt: integer("createdAt").notNull()
},
(t) => [
index("idx_ai_budget_breach_events_budget_created").on(
t.budgetId,
t.createdAt
)
]
);
// 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 ms
},
(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(),
domainId: text("domainId").references(() => domains.domainId, {
onDelete: "cascade"
}),
wildcard: integer("wildcard", { mode: "boolean" }).default(false),
status: text("status").notNull().default("pending"), // pending, requested, valid, expired, failed
expiresAt: integer("expiresAt"),
lastRenewalAttempt: integer("lastRenewalAttempt"),
createdAt: integer("createdAt").notNull(),
updatedAt: integer("updatedAt").notNull(),
orderId: text("orderId"),
errorMessage: text("errorMessage"),
renewalCount: integer("renewalCount").default(0),
certFile: text("certFile"),
keyFile: text("keyFile")
});
export type Org = InferSelectModel<typeof orgs>;
export type User = InferSelectModel<typeof users>;
export type Site = InferSelectModel<typeof sites>;
@@ -1531,6 +2083,10 @@ export type Idp = InferSelectModel<typeof idp>;
export type ApiKey = InferSelectModel<typeof apiKeys>;
export type ApiKeyAction = InferSelectModel<typeof apiKeyActions>;
export type ApiKeyOrg = InferSelectModel<typeof apiKeyOrg>;
export type VirtualApiKey = InferSelectModel<typeof virtualApiKeys>;
export type VirtualApiKeyResource = InferSelectModel<
typeof virtualApiKeyResources
>;
export type SiteResource = InferSelectModel<typeof siteResources>;
export type Network = InferSelectModel<typeof networks>;
export type OrgDomains = InferSelectModel<typeof orgDomains>;
@@ -1549,6 +2105,7 @@ export type RoundTripMessageTracker = InferSelectModel<
>;
export type StatusHistory = InferSelectModel<typeof statusHistory>;
export type Label = InferSelectModel<typeof labels>;
export type LauncherView = InferSelectModel<typeof launcherViews>;
export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
export type ResourcePolicyPincode = InferSelectModel<
typeof resourcePolicyPincode
@@ -1561,3 +2118,16 @@ export type ResourcePolicyHeaderAuth = InferSelectModel<
>;
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
export type UserPolicy = InferSelectModel<typeof userPolicies>;
export type AiProvider = InferSelectModel<typeof aiProviders>;
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
>;
export type ResourceAiModel = InferSelectModel<typeof resourceAiModels>;
export type SiteResourceAiModel = InferSelectModel<typeof siteResourceAiModels>;
export type Certificate = InferSelectModel<typeof certificates>;
+30 -3
View File
@@ -31,9 +31,24 @@ export type AlertNotificationProps = {
orgId: string;
data: Record<string, unknown>;
dashboardLink: string;
isTestAlert?: boolean;
};
function getEventMeta(eventType: AlertEventType): {
function getEventMeta(
eventType: AlertEventType,
isTestAlert: boolean = false
): {
heading: string;
previewText: string;
summary: string;
statusLabel: string | null;
statusColor: string | null;
} {
const meta = getBaseEventMeta(eventType);
return isTestAlert ? { ...meta, heading: `[TEST] ${meta.heading}` } : meta;
}
function getBaseEventMeta(eventType: AlertEventType): {
heading: string;
previewText: string;
summary: string;
@@ -180,8 +195,14 @@ function formatDataItems(
}
export const AlertNotification = (props: AlertNotificationProps) => {
const { eventType, orgId, data, dashboardLink } = props;
const meta = getEventMeta(eventType);
const {
eventType,
orgId,
data,
dashboardLink,
isTestAlert = false
} = props;
const meta = getEventMeta(eventType, isTestAlert);
const dataItems = formatDataItems(data);
const isToggle =
@@ -242,6 +263,12 @@ export const AlertNotification = (props: AlertNotificationProps) => {
Open your dashboard to view more details and manage
your alert rules.
</EmailText>
{isTestAlert && (
<EmailText>
This is a test alert. No action is required,
and no real event has occurred.
</EmailText>
)}
<EmailSection>
<ButtonLink href={dashboardLink}>
@@ -0,0 +1,78 @@
import React from "react";
import { Body, Head, Html, Preview, Tailwind } from "@react-email/components";
import { themeColors } from "./lib/theme";
import {
EmailContainer,
EmailFooter,
EmailGreeting,
EmailHeading,
EmailInfoSection,
EmailLetterHead,
EmailSection,
EmailSignature,
EmailText
} from "./components/Email";
type IdentityApiKeyGeneratedProps = {
orgName: string;
accountLabel?: string | null;
credential: string;
resourceUrls: string[];
hasMoreResources: boolean;
};
export const IdentityApiKeyGenerated = ({
orgName,
accountLabel,
credential,
resourceUrls,
hasMoreResources
}: IdentityApiKeyGeneratedProps) => {
const previewText = `Your personal identity key for ${orgName}`;
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind config={themeColors}>
<Body className="font-sans bg-gray-50">
<EmailContainer>
<EmailLetterHead />
<EmailGreeting>Hi there,</EmailGreeting>
<EmailText>
This is your personal identity key for{" "}
<strong>{orgName}</strong>. It belongs to your
account and identifies you when you use public AI
gateways.
</EmailText>
<EmailText>
Use it with resources your administrator has granted
you, or that your role has access to. Treat this key
like a password and do not share it.
</EmailText>
<EmailSection>
<EmailText>Your identity key:</EmailText>
<div className="inline-block max-w-full">
<div className="bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 mx-auto text-left">
<span className="text-sm font-mono text-gray-900 break-all">
{credential}
</span>
</div>
</div>
</EmailSection>
<EmailFooter>
<EmailSignature />
</EmailFooter>
</EmailContainer>
</Body>
</Tailwind>
</Html>
);
};
export default IdentityApiKeyGenerated;
+13 -11
View File
@@ -30,14 +30,14 @@ export const NotifyTrialExpiring = ({
const isLastDay = daysRemaining === 1;
const previewText = hasEnded
? `Your trial for ${orgName} has ended.`
? `Your cloud trial for ${orgName} has ended.`
: isLastDay
? `Your trial for ${orgName} ends tomorrow.`
: `Your trial for ${orgName} ends in ${daysRemaining} days.`;
? `Your cloud trial for ${orgName} ends tomorrow.`
: `Your cloud trial for ${orgName} ends in ${daysRemaining} days.`;
const heading = hasEnded
? "Your Trial Ended"
: "Your Trial is Ending Soon";
? "Your Cloud Trial Ended"
: "Your Cloud Trial is Ending Soon";
return (
<Html>
@@ -55,7 +55,7 @@ export const NotifyTrialExpiring = ({
{hasEnded ? (
<>
<EmailText>
Your free trial for{" "}
Your cloud free trial for{" "}
<strong>{orgName}</strong> ended on{" "}
<strong>{trialEndsAt}</strong>. Your account
has been moved to the free plan, which
@@ -64,10 +64,11 @@ export const NotifyTrialExpiring = ({
<EmailText>
Some features and resources may now be
restricted. To restore full
access and continue using all the features
you had during your trial, please upgrade to
a paid plan.
restricted. To restore full access and
continue using all the features you had
during your trial, please upgrade to a paid
plan. This does not effect any self hosted
licenses.
</EmailText>
<EmailText>
@@ -93,7 +94,8 @@ export const NotifyTrialExpiring = ({
<EmailText>
After your trial ends, your account will be
moved to the free plan and some
functionality may be restricted.
functionality may be restricted. This does
not effect any self hosted licenses.
</EmailText>
<EmailText>
@@ -0,0 +1,119 @@
import React from "react";
import { Body, Head, Html, Preview, Tailwind } from "@react-email/components";
import { themeColors } from "./lib/theme";
import {
EmailContainer,
EmailFooter,
EmailGreeting,
EmailInfoSection,
EmailLetterHead,
EmailSection,
EmailSignature,
EmailText
} from "./components/Email";
type VirtualApiKeyGeneratedProps = {
orgName: string;
keyName: string | null;
credential: string;
resourceUrls: string[];
hasMoreResources: boolean;
};
export const VirtualApiKeyGenerated = ({
orgName,
keyName,
credential,
resourceUrls,
hasMoreResources
}: VirtualApiKeyGeneratedProps) => {
const previewText = `A virtual API key for ${orgName} has been shared with you`;
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind config={themeColors}>
<Body className="font-sans bg-gray-50">
<EmailContainer>
<EmailLetterHead />
<EmailGreeting>Hi there,</EmailGreeting>
<EmailText>
A virtual API key for <strong>{orgName}</strong> has
been shared with you. This key grants access to the
public AI gateways it was created for. Treat this
key like a password and do not share it.
</EmailText>
<EmailSection>
<EmailText>Your virtual API key:</EmailText>
<div className="inline-block max-w-full">
<div className="bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 mx-auto text-left">
<span className="text-sm font-mono text-gray-900 break-all">
{credential}
</span>
</div>
</div>
</EmailSection>
<EmailInfoSection
title="Key details"
items={[
{
label: "Organization",
value: orgName
},
...(keyName
? [
{
label: "Name",
value: keyName
}
]
: [])
]}
/>
{resourceUrls.length > 0 && (
<>
<EmailText>
This key can be used to authenticate to the
following AI gateway resources:
</EmailText>
<div className="px-6 pb-2">
{resourceUrls.map((url) => (
<p
key={url}
className="text-base text-gray-700 leading-relaxed"
>
<a
href={url}
className="text-primary font-medium break-all"
>
{url}
</a>
</p>
))}
</div>
{hasMoreResources && (
<EmailText>
Contact your administrator to get the
full list.
</EmailText>
)}
</>
)}
<EmailFooter>
<EmailSignature />
</EmailFooter>
</EmailContainer>
</Body>
</Tailwind>
</Html>
);
};
export default VirtualApiKeyGenerated;
+1 -1
View File
@@ -18,7 +18,7 @@ export function EmailLetterHead() {
<Img
src="https://fossorial-public-assets.s3.us-east-1.amazonaws.com/word_mark_black.png"
alt="Pangolin Logo"
width="180"
width="135"
height="auto"
className="mx-auto"
/>
+16 -9
View File
@@ -5,26 +5,29 @@ import { runSetupFunctions } from "./setup";
import { createApiServer } from "./apiServer";
import { createNextServer } from "./nextServer";
import { createInternalServer } from "./internalServer";
import { createAiGatewayServer } from "./aiGatewayServer";
import { createIntegrationApiServer } from "./integrationApiServer";
import {
ApiKey,
ApiKeyOrg,
AiBudget,
AiModel,
AiProvider,
RemoteExitNode,
Session,
SiteResource,
User,
UserOrg
UserOrg,
VirtualApiKey
} from "@server/db";
import config from "@server/lib/config";
import { setHostMeta } from "@server/lib/hostMeta";
import { initTelemetryClient } from "@server/lib/telemetry";
import { TraefikConfigManager } from "@server/lib/traefik/TraefikConfigManager";
import { initCleanup } from "#dynamic/cleanup";
import { startSchedulers } from "#dynamic/startSchedulers";
import license from "#dynamic/license/license";
import { initLogCleanupInterval } from "@server/lib/cleanupLogs";
import { initAcmeCertSync } from "#dynamic/lib/acmeCertSync";
import { fetchServerIp } from "@server/lib/serverIpService";
import { startRebuildQueueProcessor } from "@server/lib/rebuildClientAssociations";
import { initAiModelCatalog } from "@server/lib/aiModelCatalog";
async function startServers() {
await setHostMeta();
@@ -38,15 +41,14 @@ async function startServers() {
await fetchServerIp();
initTelemetryClient();
await initAiModelCatalog();
initLogCleanupInterval();
initAcmeCertSync();
startRebuildQueueProcessor();
startSchedulers();
// Start all servers
const apiServer = createApiServer();
const internalServer = createInternalServer();
const aiGatewayServer = createAiGatewayServer();
const nextServer = await createNextServer();
if (config.getRawConfig().traefik.file_mode) {
@@ -65,6 +67,7 @@ async function startServers() {
apiServer,
nextServer,
internalServer,
aiGatewayServer,
integrationServer
};
}
@@ -83,6 +86,10 @@ declare global {
userOrgIds?: string[];
remoteExitNode?: RemoteExitNode;
siteResource?: SiteResource;
aiProvider?: AiProvider;
aiModel?: AiModel;
aiBudget?: AiBudget;
virtualApiKey?: VirtualApiKey;
orgPolicyAllowed?: boolean;
}
}
+3 -2
View File
@@ -12,7 +12,7 @@ import { logIncomingMiddleware } from "./middlewares/logIncoming";
import helmet from "helmet";
import swaggerUi from "swagger-ui-express";
import { OpenApiGeneratorV3 } from "@asteasolutions/zod-to-openapi";
import { registry } from "./openApi";
import { registry, openApiTags } from "./openApi";
import fs from "fs";
import path from "path";
import { APP_PATH } from "./lib/consts";
@@ -181,7 +181,8 @@ function getOpenApiDocumentation() {
version: "v1",
title: "Pangolin Integration API"
},
servers: [{ url: "/v1" }]
servers: [{ url: "/v1" }],
tags: openApiTags
});
if (!process.env.DISABLE_GEN_OPENAPI) {
+865 -2
View File
@@ -1,3 +1,866 @@
import fs from "fs";
import path from "path";
import crypto from "crypto";
import {
certificates,
clients,
clientSiteResourcesAssociationsCache,
db,
domains,
newts,
siteNetworks,
SiteResource,
siteResources
} from "@server/db";
import { and, eq } from "drizzle-orm";
import { encrypt, decrypt } from "@server/lib/crypto";
import logger from "@server/logger";
import config from "@server/lib/config";
import {
generateSubnetProxyTargetV2,
SubnetProxyTargetV2
} from "@server/lib/ip";
import { updateTargets } from "@server/routers/client/targets";
import cache from "#dynamic/lib/cache";
import { build } from "@server/build";
interface AcmeCert {
domain: { main: string; sans?: string[] };
certificate: string;
key: string;
Store: string;
}
interface AcmeJson {
[resolver: string]: {
Certificates: AcmeCert[];
};
}
export async function pushCertUpdateToAffectedNewts(
domain: string,
domainId: string | null,
oldCertPem: string | null,
oldKeyPem: string | null
): Promise<void> {
// Find all SSL-enabled HTTP site resources that use this cert's domain
let affectedResources: SiteResource[] = [];
if (domainId) {
affectedResources = await db
.select()
.from(siteResources)
.where(
and(
eq(siteResources.domainId, domainId),
eq(siteResources.ssl, true)
)
);
} else {
// Fallback: match by exact fullDomain when no domainId is available
affectedResources = await db
.select()
.from(siteResources)
.where(
and(
eq(siteResources.fullDomain, domain),
eq(siteResources.ssl, true)
)
);
}
if (affectedResources.length === 0) {
logger.debug(
`acmeCertSync: no affected site resources for cert domain "${domain}"`
);
return;
}
logger.debug(
`acmeCertSync: pushing cert update to ${affectedResources.length} affected site resource(s) for domain "${domain}"`
);
for (const resource of affectedResources) {
try {
// Get all sites for this resource via siteNetworks
const resourceSiteRows = resource.networkId
? await db
.select({ siteId: siteNetworks.siteId })
.from(siteNetworks)
.where(eq(siteNetworks.networkId, resource.networkId))
: [];
if (resourceSiteRows.length === 0) {
logger.debug(
`acmeCertSync: no sites for resource ${resource.siteResourceId}, skipping`
);
continue;
}
// Get all clients with access to this resource
const resourceClients = await db
.select({
clientId: clients.clientId,
pubKey: clients.pubKey,
subnet: clients.subnet
})
.from(clients)
.innerJoin(
clientSiteResourcesAssociationsCache,
eq(
clients.clientId,
clientSiteResourcesAssociationsCache.clientId
)
)
.where(
eq(
clientSiteResourcesAssociationsCache.siteResourceId,
resource.siteResourceId
)
);
if (resourceClients.length === 0) {
logger.debug(
`acmeCertSync: no clients for resource ${resource.siteResourceId}, skipping`
);
continue;
}
// Invalidate the cert cache so generateSubnetProxyTargetV2 fetches fresh data
if (resource.fullDomain) {
await cache.del(`cert:${resource.fullDomain}`);
}
// Generate target once - same cert applies to all sites for this resource
const newTargets = await generateSubnetProxyTargetV2(
resource,
resourceClients
);
if (!newTargets) {
logger.debug(
`acmeCertSync: could not generate target for resource ${resource.siteResourceId}, skipping`
);
continue;
}
// Construct the old targets - same routing shape but with the previous cert/key.
// The newt only uses destPrefix/sourcePrefixes for removal, but we keep the
// semantics correct so the update message accurately reflects what changed.
const oldTargets: SubnetProxyTargetV2[] = newTargets.map((t) => ({
...t,
tlsCert: oldCertPem ?? undefined,
tlsKey: oldKeyPem ?? undefined
}));
// Push update to each site's newt
for (const { siteId } of resourceSiteRows) {
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, siteId))
.limit(1);
if (!newt) {
logger.debug(
`acmeCertSync: no newt found for site ${siteId}, skipping resource ${resource.siteResourceId}`
);
continue;
}
await updateTargets(
newt.newtId,
{ oldTargets: oldTargets, newTargets: newTargets },
newt.version
);
logger.debug(
`acmeCertSync: pushed cert update to newt for site ${siteId}, resource ${resource.siteResourceId}`
);
}
} catch (err) {
logger.error(
`acmeCertSync: error pushing cert update for resource ${resource?.siteResourceId}: ${err}`
);
}
}
}
async function findDomainId(certDomain: string): Promise<string | null> {
// Strip wildcard prefix before lookup (*.example.com -> example.com)
const lookupDomain = certDomain.startsWith("*.")
? certDomain.slice(2)
: certDomain;
// 1. Exact baseDomain match (any domain type)
const exactMatch = await db
.select({ domainId: domains.domainId })
.from(domains)
.where(eq(domains.baseDomain, lookupDomain))
.limit(1);
if (exactMatch.length > 0) {
return exactMatch[0].domainId;
}
// 2. Walk up the domain hierarchy looking for a wildcard-type domain whose
// baseDomain is a suffix of the cert domain. e.g. cert "sub.example.com"
// matches a wildcard domain with baseDomain "example.com".
const parts = lookupDomain.split(".");
for (let i = 1; i < parts.length; i++) {
const candidate = parts.slice(i).join(".");
if (!candidate) continue;
const wildcardMatch = await db
.select({ domainId: domains.domainId })
.from(domains)
.where(
and(
eq(domains.baseDomain, candidate),
eq(domains.type, "wildcard")
)
)
.limit(1);
if (wildcardMatch.length > 0) {
return wildcardMatch[0].domainId;
}
}
return null;
}
function extractFirstCert(pemBundle: string): string | null {
const match = pemBundle.match(
/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/
);
return match ? match[0] : null;
}
/**
* Determine whether an ACME cert entry represents a wildcard cert by checking
* both the primary domain (`main`) and the SANs. Some ACME clients (notably
* Traefik) store the bare apex in `main` and only put the wildcard form in
* `sans` (e.g. main="access.example.com", sans=["*.access.example.com"]).
*/
function detectWildcard(
main: string,
sans: string[] | undefined
): { wildcard: boolean; wildcardSan: string | null } {
if (main.startsWith("*.")) {
return { wildcard: true, wildcardSan: null };
}
if (Array.isArray(sans)) {
for (const san of sans) {
if (typeof san !== "string") continue;
if (san === `*.${main}` || san.startsWith("*.")) {
return { wildcard: true, wildcardSan: san };
}
}
}
return { wildcard: false, wildcardSan: null };
}
interface HttpCert {
wildcard: boolean;
altName: string;
certName: string;
commonName: string;
certFile: string;
keyFile: string;
}
async function syncAcmeCertsFromHttp(endpoint: string): Promise<void> {
let response: Response;
try {
response = await fetch(endpoint);
} catch (err) {
logger.debug(
`acmeCertSync: could not reach HTTP endpoint ${endpoint}: ${err}`
);
return;
}
if (!response.ok) {
logger.debug(
`acmeCertSync: HTTP endpoint returned status ${response.status}`
);
return;
}
let httpCerts: HttpCert[];
try {
httpCerts = await response.json();
} catch (err) {
logger.debug(
`acmeCertSync: could not parse JSON from HTTP endpoint: ${err}`
);
return;
}
if (!Array.isArray(httpCerts) || httpCerts.length === 0) {
logger.debug(
`acmeCertSync: no certificates returned from HTTP endpoint`
);
return;
}
for (const cert of httpCerts) {
const domain = cert?.certName;
if (!domain || typeof domain !== "string") {
logger.debug(
`acmeCertSync: skipping HTTP cert with missing certName`
);
continue;
}
const certPem = cert.certFile;
const keyPem = cert.keyFile;
if (!certPem?.trim() || !keyPem?.trim()) {
logger.debug(
`acmeCertSync: skipping HTTP cert for ${domain} - empty certFile or keyFile`
);
continue;
}
const firstCertPemForValidation = extractFirstCert(certPem);
if (!firstCertPemForValidation) {
logger.debug(
`acmeCertSync: skipping HTTP cert for ${domain} - no PEM certificate block found`
);
continue;
}
let validatedX509: crypto.X509Certificate;
try {
validatedX509 = new crypto.X509Certificate(
firstCertPemForValidation
);
} catch (err) {
logger.debug(
`acmeCertSync: skipping HTTP cert for ${domain} - invalid X.509 certificate: ${err}`
);
continue;
}
try {
crypto.createPrivateKey(keyPem);
} catch (err) {
logger.debug(
`acmeCertSync: skipping HTTP cert for ${domain} - invalid private key: ${err}`
);
continue;
}
const wildcard = cert.wildcard ?? false;
const existing = await db
.select()
.from(certificates)
.where(eq(certificates.domain, domain))
.limit(1);
let oldCertPem: string | null = null;
let oldKeyPem: string | null = null;
if (existing.length > 0 && existing[0].certFile) {
try {
const storedCertPem = decrypt(
existing[0].certFile,
config.getRawConfig().server.secret!
);
const wildcardUnchanged = existing[0].wildcard === wildcard;
if (storedCertPem === certPem && wildcardUnchanged) {
continue;
}
oldCertPem = storedCertPem;
if (existing[0].keyFile) {
try {
oldKeyPem = decrypt(
existing[0].keyFile,
config.getRawConfig().server.secret!
);
} catch (keyErr) {
logger.debug(
`acmeCertSync: could not decrypt stored key for ${domain}: ${keyErr}`
);
}
}
} catch (err) {
logger.debug(
`acmeCertSync: could not decrypt stored cert for ${domain}, will update: ${err}`
);
}
}
let expiresAt: number | null = null;
try {
expiresAt = Math.floor(
new Date(validatedX509.validTo).getTime() / 1000
);
} catch (err) {
logger.debug(
`acmeCertSync: could not parse cert expiry for ${domain}: ${err}`
);
}
const encryptedCert = encrypt(
certPem,
config.getRawConfig().server.secret!
);
const encryptedKey = encrypt(
keyPem,
config.getRawConfig().server.secret!
);
const now = Math.floor(Date.now() / 1000);
const domainId = await findDomainId(domain);
if (domainId) {
logger.debug(
`acmeCertSync: resolved domainId "${domainId}" for HTTP cert domain "${domain}"`
);
} else {
logger.debug(
`acmeCertSync: no matching domain record found for HTTP cert domain "${domain}"`
);
}
if (existing.length > 0) {
logger.debug(
`acmeCertSync: updating existing certificate (HTTP) for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
);
await db
.update(certificates)
.set({
certFile: encryptedCert,
keyFile: encryptedKey,
status: "valid",
expiresAt,
updatedAt: now,
wildcard,
...(domainId !== null && { domainId })
})
.where(eq(certificates.domain, domain));
await pushCertUpdateToAffectedNewts(
domain,
domainId,
oldCertPem,
oldKeyPem
);
} else {
logger.debug(
`acmeCertSync: inserting new certificate (HTTP) for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
);
await db.insert(certificates).values({
domain,
domainId,
certFile: encryptedCert,
keyFile: encryptedKey,
status: "valid",
expiresAt,
createdAt: now,
updatedAt: now,
wildcard
});
await pushCertUpdateToAffectedNewts(domain, domainId, null, null);
}
}
}
async function storeCertForDomain(
domain: string,
certPem: string,
keyPem: string,
validatedX509: crypto.X509Certificate
): Promise<void> {
const wildcard = domain.startsWith("*.");
const existing = await db
.select()
.from(certificates)
.where(eq(certificates.domain, domain))
.limit(1);
let oldCertPem: string | null = null;
let oldKeyPem: string | null = null;
if (existing.length > 0 && existing[0].certFile) {
try {
const storedCertPem = decrypt(
existing[0].certFile,
config.getRawConfig().server.secret!
);
const wildcardUnchanged = existing[0].wildcard === wildcard;
if (storedCertPem === certPem && wildcardUnchanged) {
return;
}
oldCertPem = storedCertPem;
if (existing[0].keyFile) {
try {
oldKeyPem = decrypt(
existing[0].keyFile,
config.getRawConfig().server.secret!
);
} catch (keyErr) {
logger.debug(
`acmeCertSync: could not decrypt stored key for ${domain}: ${keyErr}`
);
}
}
} catch (err) {
logger.debug(
`acmeCertSync: could not decrypt stored cert for ${domain}, will update: ${err}`
);
}
}
let expiresAt: number | null = null;
try {
expiresAt = Math.floor(
new Date(validatedX509.validTo).getTime() / 1000
);
} catch (err) {
logger.debug(
`acmeCertSync: could not parse cert expiry for ${domain}: ${err}`
);
}
const encryptedCert = encrypt(
certPem,
config.getRawConfig().server.secret!
);
const encryptedKey = encrypt(keyPem, config.getRawConfig().server.secret!);
const now = Math.floor(Date.now() / 1000);
const domainId = await findDomainId(domain);
if (domainId) {
logger.debug(
`acmeCertSync: resolved domainId "${domainId}" for cert domain "${domain}"`
);
} else {
logger.debug(
`acmeCertSync: no matching domain record found for cert domain "${domain}"`
);
}
if (existing.length > 0) {
logger.debug(
`acmeCertSync: updating existing certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
);
await db
.update(certificates)
.set({
certFile: encryptedCert,
keyFile: encryptedKey,
status: "valid",
expiresAt,
updatedAt: now,
wildcard,
...(domainId !== null && { domainId })
})
.where(eq(certificates.domain, domain));
logger.debug(
`acmeCertSync: updated certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
);
await pushCertUpdateToAffectedNewts(
domain,
domainId,
oldCertPem,
oldKeyPem
);
} else {
logger.debug(
`acmeCertSync: inserting new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
);
await db.insert(certificates).values({
domain,
domainId,
certFile: encryptedCert,
keyFile: encryptedKey,
status: "valid",
expiresAt,
createdAt: now,
updatedAt: now,
wildcard
});
logger.debug(
`acmeCertSync: inserted new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
);
await pushCertUpdateToAffectedNewts(domain, domainId, null, null);
}
}
function findAcmeJsonFiles(dirPath: string): string[] {
const results: string[] = [];
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dirPath, { withFileTypes: true });
} catch (err) {
logger.warn(
`acmeCertSync: could not read directory "${dirPath}": ${err}`
);
return results;
}
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
results.push(...findAcmeJsonFiles(fullPath));
} else if (entry.isFile()) {
// check if it is a json file
if (entry.name.endsWith(".json")) {
let raw: string;
try {
raw = fs.readFileSync(fullPath, "utf8");
} catch (err) {
logger.warn(
`acmeCertSync: could not read file "${fullPath}": ${err}`
);
continue;
}
let parsed: any;
try {
parsed = JSON.parse(raw);
} catch (err) {
logger.warn(
`acmeCertSync: could not parse "${fullPath}" as JSON: ${err}`
);
continue;
}
}
results.push(fullPath);
}
}
return results;
}
async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
let raw: string;
try {
raw = fs.readFileSync(acmeJsonPath, "utf8");
} catch (err) {
logger.warn(`acmeCertSync: could not read "${acmeJsonPath}": ${err}`);
return;
}
let acmeJson: AcmeJson;
try {
acmeJson = JSON.parse(raw);
} catch (err) {
logger.warn(
`acmeCertSync: could not parse "${acmeJsonPath}" as JSON: ${err}`
);
return;
}
const resolvers = Object.keys(acmeJson || {});
if (resolvers.length === 0) {
logger.debug(`acmeCertSync: no resolvers found in acme.json`);
return;
}
// Collect certificates from every resolver. If the same domain appears in
// multiple resolvers, the last one wins (resolvers iterated in object order).
const allCerts: AcmeCert[] = [];
for (const resolver of resolvers) {
const resolverData = acmeJson[resolver];
if (!resolverData || !Array.isArray(resolverData.Certificates)) {
logger.debug(
`acmeCertSync: no certificates found for resolver "${resolver}"`
);
continue;
}
// logger.debug(
// `acmeCertSync: found ${resolverData.Certificates.length} certificate(s) for resolver "${resolver}"`
// );
for (const cert of resolverData.Certificates) {
allCerts.push(cert);
}
}
for (const cert of allCerts) {
const mainDomain = cert?.domain?.main;
if (!mainDomain || typeof mainDomain !== "string") {
logger.debug(`acmeCertSync: skipping cert with missing domain`);
continue;
}
if (!cert.certificate || !cert.key) {
logger.debug(
`acmeCertSync: skipping cert for ${mainDomain} - empty certificate or key field`
);
continue;
}
let certPem: string;
let keyPem: string;
try {
certPem = Buffer.from(cert.certificate, "base64").toString("utf8");
keyPem = Buffer.from(cert.key, "base64").toString("utf8");
} catch (err) {
logger.debug(
`acmeCertSync: skipping cert for ${mainDomain} - failed to base64-decode cert/key: ${err}`
);
continue;
}
if (!certPem.trim() || !keyPem.trim()) {
logger.debug(
`acmeCertSync: skipping cert for ${mainDomain} - blank PEM after base64 decode`
);
continue;
}
// Validate that the decoded data actually parses as a real X.509 cert
// before we touch the database. This prevents importing partially-written
// or corrupted entries from acme.json.
const firstCertPemForValidation = extractFirstCert(certPem);
if (!firstCertPemForValidation) {
logger.debug(
`acmeCertSync: skipping cert for ${mainDomain} - no PEM certificate block found`
);
continue;
}
let validatedX509: crypto.X509Certificate;
try {
validatedX509 = new crypto.X509Certificate(
firstCertPemForValidation
);
} catch (err) {
logger.debug(
`acmeCertSync: skipping cert for ${mainDomain} - invalid X.509 certificate: ${err}`
);
continue;
}
// Sanity-check the private key parses too
try {
crypto.createPrivateKey(keyPem);
} catch (err) {
logger.debug(
`acmeCertSync: skipping cert for ${mainDomain} - invalid private key: ${err}`
);
continue;
}
// Collect all domains covered by this cert: main + every SAN.
// Each domain gets its own row in the certificates table so that
// lookups by any hostname on the cert succeed independently.
const allDomains = new Set<string>([mainDomain]);
if (Array.isArray(cert.domain?.sans)) {
for (const san of cert.domain.sans) {
if (typeof san === "string" && san.trim()) {
allDomains.add(san.trim());
}
}
}
// logger.debug(
// `acmeCertSync: cert for ${mainDomain} covers ${allDomains.size} domain(s): ${[...allDomains].join(", ")}`
// );
for (const domain of allDomains) {
try {
await storeCertForDomain(
domain,
certPem,
keyPem,
validatedX509
);
} catch (err) {
logger.error(
`acmeCertSync: error storing cert for domain "${domain}": ${err}`
);
}
}
}
}
export function initAcmeCertSync(): void {
// stub
}
if (build == "saas") {
logger.debug(`acmeCertSync: skipping ACME cert sync in SaaS build`);
return;
}
const configData = config.getRawConfig();
if (!configData.flags?.enable_acme_cert_sync) {
logger.debug(
`acmeCertSync: ACME cert sync is disabled by config flag, skipping`
);
return;
}
const acmeJsonPath =
configData.acme?.acme_json_path ?? "config/letsencrypt/acme.json";
const intervalMs = configData.acme?.sync_interval_ms ?? 5000;
const httpEndpoint = configData.acme?.acme_http_endpoint;
logger.debug(
`acmeCertSync: starting ACME cert sync from "${acmeJsonPath}" across all resolvers every ${intervalMs}ms`
);
if (httpEndpoint) {
logger.debug(
`acmeCertSync: also syncing from HTTP endpoint "${httpEndpoint}" every ${intervalMs}ms`
);
}
const runSync = () => {
if (httpEndpoint) {
syncAcmeCertsFromHttp(httpEndpoint).catch((err) => {
logger.error(`acmeCertSync: error during HTTP sync: ${err}`);
});
} else {
// only run the file-based sync if the HTTP endpoint is not configured, to avoid doubling up
let stat: fs.Stats | null = null;
try {
stat = fs.statSync(acmeJsonPath);
} catch (err) {
logger.warn(
`acmeCertSync: cannot stat path "${acmeJsonPath}": ${err}`
);
return;
}
if (stat.isDirectory()) {
const files = findAcmeJsonFiles(acmeJsonPath);
if (files.length === 0) {
logger.debug(
`acmeCertSync: no acme.json files found in directory "${acmeJsonPath}"`
);
return;
}
// logger.debug(
// `acmeCertSync: found ${files.length} acme.json file(s) in directory "${acmeJsonPath}"`
// );
for (const file of files) {
syncAcmeCerts(file).catch((err) => {
logger.error(
`acmeCertSync: error during sync of "${file}": ${err}`
);
});
}
} else {
syncAcmeCerts(acmeJsonPath).catch((err) => {
logger.error(`acmeCertSync: error during sync: ${err}`);
});
}
}
};
// Run immediately on init, then on the configured interval
runSync();
setInterval(runSync, intervalMs);
}
+613
View File
@@ -0,0 +1,613 @@
import {
and,
eq,
gte,
inArray,
isNull,
or,
sql,
SQL,
type InferInsertModel
} from "drizzle-orm";
import {
AiBudget,
aiBudgetBreachEvents,
aiBudgets,
aiModels,
aiUsageRecords,
db,
userOrgRoles
} from "@server/db";
import { modelKeyMatches } from "@server/lib/aiModelKeyMatch";
import type { AiUsage } from "@server/lib/aiUsageExtraction";
import { regionalCache as cache } from "#dynamic/lib/cache";
import logger from "@server/logger";
type BudgetPeriod = AiBudget["period"];
const PERIOD_DURATIONS_MS: Record<Exclude<BudgetPeriod, "lifetime">, number> = {
hourly: 60 * 60 * 1000,
daily: 24 * 60 * 60 * 1000,
weekly: 7 * 24 * 60 * 60 * 1000,
monthly: 30 * 24 * 60 * 60 * 1000,
yearly: 365 * 24 * 60 * 60 * 1000
};
// Budgets are cheap to be a little stale about (enforcement is already
// check-then-act, not transactional). Re-derive each budget's usage sum
// from aiUsageRecords at most this often; in between, completed requests
// just add their own contribution onto the cached sum instead of
// re-querying/re-aggregating from scratch.
const BUDGET_CACHE_REFRESH_MS = 8_000;
// Redis-level TTL is only a safety net for eviction if a budget stops
// seeing traffic - the actual staleness check is the computedAt timestamp
// stored in the cached value, compared against BUDGET_CACHE_REFRESH_MS.
const BUDGET_CACHE_SAFETY_TTL_SEC = 60;
function applicableBudgetsCacheKey(ctx: BudgetScopeContext): string {
const roleKey = [...ctx.roleIds].sort((a, b) => a - b).join(",");
return [
"aiBudget:applicable",
ctx.orgId,
ctx.providerId,
ctx.requestedModel,
ctx.resourceId ?? "",
ctx.siteResourceId ?? "",
roleKey,
ctx.virtualApiKeyId ?? ""
].join(":");
}
function budgetUsageCacheKey(budgetId: number): string {
return `aiBudget:usage:${budgetId}`;
}
type CachedBudgetUsage = {
sum: number;
computedAt: number;
};
// Budget periods are trailing windows from "now", not calendar-aligned
// (e.g. "daily" = last 24h). "lifetime" has no lower bound.
function windowStart(period: BudgetPeriod, now: number): number {
if (period === "lifetime") {
return 0;
}
return now - PERIOD_DURATIONS_MS[period];
}
export type BudgetScopeContext = {
orgId: string;
providerId: number;
requestedModel: string;
resourceId: number | null;
siteResourceId: number | null;
roleIds: number[];
requestUserId: string | null;
virtualApiKeyId: string | null;
};
/**
* Every budget that could apply to this request: the provider itself, any
* model on that provider whose (possibly wildcarded) modelKey matches the
* requested model, the target resource/site-resource, and any role the
* requesting user holds in the org. Cached for BUDGET_CACHE_REFRESH_MS since
* budget/model config changes are rare and a request-scoped org/provider/
* model/resource/role combination repeats constantly under real traffic.
*/
export async function resolveApplicableBudgets(
ctx: BudgetScopeContext
): Promise<AiBudget[]> {
const cacheKey = applicableBudgetsCacheKey(ctx);
const cached = await cache.get<AiBudget[]>(cacheKey);
if (cached !== undefined) {
return cached;
}
const budgets = await fetchApplicableBudgets(ctx);
await cache.set(cacheKey, budgets, BUDGET_CACHE_REFRESH_MS / 1000);
return budgets;
}
async function fetchApplicableBudgets(
ctx: BudgetScopeContext
): Promise<AiBudget[]> {
const providerModels = await db
.select({ modelId: aiModels.modelId, modelKey: aiModels.modelKey })
.from(aiModels)
.where(
and(
eq(aiModels.providerId, ctx.providerId),
eq(aiModels.enabled, true)
)
);
const matchingModelIds = providerModels
.filter((m) => modelKeyMatches(m.modelKey, ctx.requestedModel))
.map((m) => m.modelId);
const scopeConditions: SQL[] = [
and(
eq(aiBudgets.providerId, ctx.providerId),
isNull(aiBudgets.modelId)
)!
];
if (matchingModelIds.length > 0) {
scopeConditions.push(inArray(aiBudgets.modelId, matchingModelIds));
}
if (ctx.resourceId != null) {
scopeConditions.push(eq(aiBudgets.resourceId, ctx.resourceId));
}
if (ctx.siteResourceId != null) {
scopeConditions.push(eq(aiBudgets.siteResourceId, ctx.siteResourceId));
}
if (ctx.roleIds.length > 0) {
scopeConditions.push(inArray(aiBudgets.roleId, ctx.roleIds));
}
if (ctx.virtualApiKeyId != null) {
scopeConditions.push(
eq(aiBudgets.virtualApiKeyId, ctx.virtualApiKeyId)
);
}
return db
.select()
.from(aiBudgets)
.where(
and(
eq(aiBudgets.orgId, ctx.orgId),
eq(aiBudgets.enabled, true),
or(...scopeConditions)
)
);
}
async function sumUsageAmount(
where: SQL,
unit: AiBudget["unit"]
): Promise<number> {
const column =
unit === "usd" ? aiUsageRecords.costUsd : aiUsageRecords.totalTokens;
const [row] = await db
.select({ total: sql<number>`coalesce(sum(${column}), 0)` })
.from(aiUsageRecords)
.where(where);
return Number(row?.total ?? 0);
}
/**
* Sums recorded usage for a single budget's scope + rolling window. Model
* budgets can't be pushed down to SQL because the model's key may itself be
* a glob, so those rows are fetched for the provider+window and matched in
* JS the same way access-control matching does.
*/
export async function sumUsageForBudget(
budget: AiBudget,
ctx: BudgetScopeContext,
now: number
): Promise<number> {
const start = windowStart(budget.period, now);
if (budget.modelId != null) {
const [model] = await db
.select({
providerId: aiModels.providerId,
modelKey: aiModels.modelKey
})
.from(aiModels)
.where(eq(aiModels.modelId, budget.modelId))
.limit(1);
if (!model) {
return 0;
}
const rows = await db
.select({
requestedModel: aiUsageRecords.requestedModel,
costUsd: aiUsageRecords.costUsd,
totalTokens: aiUsageRecords.totalTokens
})
.from(aiUsageRecords)
.where(
and(
eq(aiUsageRecords.orgId, ctx.orgId),
eq(aiUsageRecords.providerId, model.providerId),
gte(aiUsageRecords.createdAt, start)
)
);
return rows
.filter((r) => modelKeyMatches(model.modelKey, r.requestedModel))
.reduce(
(sum, r) =>
sum +
(budget.unit === "usd" ? (r.costUsd ?? 0) : r.totalTokens),
0
);
}
if (budget.providerId != null) {
return sumUsageAmount(
and(
eq(aiUsageRecords.orgId, ctx.orgId),
eq(aiUsageRecords.providerId, budget.providerId),
gte(aiUsageRecords.createdAt, start)
)!,
budget.unit
);
}
if (budget.resourceId != null) {
return sumUsageAmount(
and(
eq(aiUsageRecords.orgId, ctx.orgId),
eq(aiUsageRecords.resourceId, budget.resourceId),
gte(aiUsageRecords.createdAt, start)
)!,
budget.unit
);
}
if (budget.siteResourceId != null) {
return sumUsageAmount(
and(
eq(aiUsageRecords.orgId, ctx.orgId),
eq(aiUsageRecords.siteResourceId, budget.siteResourceId),
gte(aiUsageRecords.createdAt, start)
)!,
budget.unit
);
}
if (budget.roleId != null) {
const members = await db
.select({ userId: userOrgRoles.userId })
.from(userOrgRoles)
.where(
and(
eq(userOrgRoles.roleId, budget.roleId),
eq(userOrgRoles.orgId, ctx.orgId)
)
);
const userIds = members.map((m) => m.userId);
if (userIds.length === 0) {
return 0;
}
return sumUsageAmount(
and(
eq(aiUsageRecords.orgId, ctx.orgId),
inArray(aiUsageRecords.userId, userIds),
gte(aiUsageRecords.createdAt, start)
)!,
budget.unit
);
}
if (budget.virtualApiKeyId != null) {
return sumUsageAmount(
and(
eq(aiUsageRecords.orgId, ctx.orgId),
eq(aiUsageRecords.virtualApiKeyId, budget.virtualApiKeyId),
gte(aiUsageRecords.createdAt, start)
)!,
budget.unit
);
}
return 0;
}
/**
* Cached wrapper around sumUsageForBudget. Reuses a per-budget cached sum
* for up to BUDGET_CACHE_REFRESH_MS, and otherwise falls through to the DB
* aggregation and reseeds the cache. Completed requests within that window
* top the cached sum up via applyUsageToBudgetCache below rather than
* forcing a re-aggregation on every request.
*/
async function getBudgetUsage(
budget: AiBudget,
ctx: BudgetScopeContext,
now: number
): Promise<number> {
const cacheKey = budgetUsageCacheKey(budget.budgetId);
const cached = await cache.get<CachedBudgetUsage>(cacheKey);
if (cached && now - cached.computedAt < BUDGET_CACHE_REFRESH_MS) {
return cached.sum;
}
const sum = await sumUsageForBudget(budget, ctx, now);
await cache.set(
cacheKey,
{ sum, computedAt: now } satisfies CachedBudgetUsage,
BUDGET_CACHE_SAFETY_TTL_SEC
);
return sum;
}
/**
* Called once a request's actual usage is known, for every budget that was
* resolved as applicable to it (i.e. checkBudgets' returned `budgets`).
* Adds this request's contribution directly onto each budget's cached sum
* so the next request in the same refresh window doesn't need to re-query
* or re-aggregate. If there's no warm cache entry, or it's already due for
* a refresh, this is a no-op - the next reader re-derives from the DB,
* which by then already includes this request's row via recordUsage.
*/
export async function applyUsageToBudgetCache(
budgets: AiBudget[],
usage: { usd: number; tokens: number }
): Promise<void> {
await Promise.all(
budgets.map(async (budget) => {
const delta = budget.unit === "usd" ? usage.usd : usage.tokens;
if (!delta) {
return;
}
const cacheKey = budgetUsageCacheKey(budget.budgetId);
const cached = await cache.get<CachedBudgetUsage>(cacheKey);
if (
!cached ||
Date.now() - cached.computedAt >= BUDGET_CACHE_REFRESH_MS
) {
return;
}
await cache.set(
cacheKey,
{
sum: cached.sum + delta,
computedAt: cached.computedAt
} satisfies CachedBudgetUsage,
BUDGET_CACHE_SAFETY_TTL_SEC
);
})
);
}
// Throttled to one durable event per budget per breach window, so a soft
// budget being exceeded doesn't write a row on every subsequent request
// while it stays over.
async function recordBreachEventIfNew(
budget: AiBudget,
ctx: BudgetScopeContext,
usageAmount: number,
now: number
): Promise<void> {
try {
const start = windowStart(budget.period, now);
const [existing] = await db
.select({ id: aiBudgetBreachEvents.id })
.from(aiBudgetBreachEvents)
.where(
and(
eq(aiBudgetBreachEvents.budgetId, budget.budgetId),
gte(aiBudgetBreachEvents.createdAt, start)
)
)
.limit(1);
if (existing) {
return;
}
await db.insert(aiBudgetBreachEvents).values({
orgId: ctx.orgId,
budgetId: budget.budgetId,
enforcement: budget.enforcement,
unit: budget.unit,
period: budget.period,
amount: budget.amount,
usageAmount,
blocked: budget.enforcement === "hard",
requestUserId: ctx.requestUserId,
createdAt: now
});
} catch (error) {
logger.error("Failed to record AI budget breach event", {
error,
budgetId: budget.budgetId
});
}
}
export type BudgetCheckResult = {
blocked: boolean;
blockingBudget?: AiBudget;
// Every budget resolved as applicable to this request, regardless of
// whether it was breached - pass to applyUsageToBudgetCache once this
// request's actual usage is known.
budgets: AiBudget[];
};
export async function checkBudgets(
ctx: BudgetScopeContext
): Promise<BudgetCheckResult> {
const budgets = await resolveApplicableBudgets(ctx);
if (budgets.length === 0) {
return { blocked: false, budgets: [] };
}
const now = Date.now();
let blockingBudget: AiBudget | undefined;
for (const budget of budgets) {
const usage = await getBudgetUsage(budget, ctx, now);
if (usage < budget.amount) {
continue;
}
await recordBreachEventIfNew(budget, ctx, usage, now);
if (budget.enforcement === "hard" && !blockingBudget) {
blockingBudget = budget;
}
}
return blockingBudget
? { blocked: true, blockingBudget, budgets }
: { blocked: false, budgets };
}
export type UsageRecordInput = {
orgId: string;
providerId: number;
resourceId: number | null;
siteResourceId: number | null;
userId: string | null;
virtualApiKeyId: string | null;
requestedModel: string;
usage: AiUsage;
costUsd: number | null;
createdAt?: number;
// Same id as the aiSessionLog row logged for this request, so the two
// can be joined to show token/cost usage alongside the session
// transcript. Undefined when the session wasn't logged (e.g. session
// log retention disabled for the org).
sessionId?: string;
};
type AiUsageRecordInsert = InferInsertModel<typeof aiUsageRecords>;
// In-memory buffer for batching AI usage record inserts, mirroring the
// approach in server/routers/badger/logRequestAudit.ts. Usage rows are read
// back on every budget-cache miss (see getBudgetUsage above), which happens
// at least every BUDGET_CACHE_REFRESH_MS, so this buffer is flushed much
// more aggressively than the request audit log to keep the table from
// lagging behind what budget enforcement needs. Unlike the audit log, there
// is no retention/cleanup job for this table - usage history is kept
// indefinitely for billing and historical reporting.
const usageRecordBuffer: AiUsageRecordInsert[] = [];
const USAGE_BATCH_SIZE = 20; // Write to DB every 20 records
const USAGE_BATCH_INTERVAL_MS = 1000; // Or every 1 second, whichever comes first
const USAGE_MAX_BUFFER_SIZE = 5000; // Prevent unbounded memory growth
let usageFlushTimer: NodeJS.Timeout | null = null;
let isUsageFlushInProgress = false;
async function flushUsageRecords() {
if (usageRecordBuffer.length === 0 || isUsageFlushInProgress) {
return;
}
isUsageFlushInProgress = true;
const recordsToWrite = usageRecordBuffer.splice(
0,
usageRecordBuffer.length
);
try {
// Use a transaction to ensure all inserts succeed or fail together
await db.transaction(async (tx) => {
// Batch insert in groups to avoid overwhelming the database
const DB_BATCH_SIZE = 25;
for (let i = 0; i < recordsToWrite.length; i += DB_BATCH_SIZE) {
const batch = recordsToWrite.slice(i, i + DB_BATCH_SIZE);
await tx.insert(aiUsageRecords).values(batch);
}
});
logger.debug(
`Flushed ${recordsToWrite.length} AI usage records to database`
);
} catch (error) {
logger.error("Error flushing AI usage records:", error);
// On transaction error, put records back at the front of the buffer
// to retry, but only if the buffer isn't too large
if (
usageRecordBuffer.length <
USAGE_MAX_BUFFER_SIZE - recordsToWrite.length
) {
usageRecordBuffer.unshift(...recordsToWrite);
logger.info(
`Re-queued ${recordsToWrite.length} AI usage records for retry`
);
} else {
logger.error(
`Buffer full, dropped ${recordsToWrite.length} AI usage records`
);
}
} finally {
isUsageFlushInProgress = false;
// If buffer filled up while we were flushing, flush again
if (usageRecordBuffer.length >= USAGE_BATCH_SIZE) {
flushUsageRecords().catch((err) =>
logger.error("Error in follow-up AI usage flush:", err)
);
}
}
}
function scheduleUsageFlush() {
if (usageFlushTimer === null) {
usageFlushTimer = setTimeout(() => {
usageFlushTimer = null;
flushUsageRecords().catch((err) =>
logger.error("Error in scheduled AI usage flush:", err)
);
}, USAGE_BATCH_INTERVAL_MS);
}
}
/**
* Gracefully flush all pending AI usage records (call this on shutdown).
*/
export async function shutdownUsageRecorder() {
if (usageFlushTimer) {
clearTimeout(usageFlushTimer);
usageFlushTimer = null;
}
// Force flush even if one is in progress by waiting and retrying
while (isUsageFlushInProgress) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
await flushUsageRecords();
}
export async function recordUsage(input: UsageRecordInput): Promise<void> {
try {
const { usage } = input;
const totalTokens =
usage.promptTokens +
usage.cacheReadTokens +
usage.cacheWriteTokens +
usage.completionTokens +
usage.reasoningTokens;
// Prevent unbounded buffer growth - drop oldest entries if buffer is too large
if (usageRecordBuffer.length >= USAGE_MAX_BUFFER_SIZE) {
const dropped = usageRecordBuffer.splice(0, USAGE_BATCH_SIZE);
logger.warn(
`AI usage record buffer exceeded max size (${USAGE_MAX_BUFFER_SIZE}), dropped ${dropped.length} oldest entries`
);
}
usageRecordBuffer.push({
orgId: input.orgId,
providerId: input.providerId,
resourceId: input.resourceId,
siteResourceId: input.siteResourceId,
userId: input.userId,
virtualApiKeyId: input.virtualApiKeyId,
sessionId: input.sessionId,
requestedModel: input.requestedModel,
promptTokens: usage.promptTokens,
cacheReadTokens: usage.cacheReadTokens,
cacheWriteTokens: usage.cacheWriteTokens,
completionTokens: usage.completionTokens,
reasoningTokens: usage.reasoningTokens,
totalTokens,
costUsd: input.costUsd,
estimated: usage.estimated,
createdAt: input.createdAt ?? Date.now()
});
// Flush immediately if buffer is full, otherwise schedule a flush
if (usageRecordBuffer.length >= USAGE_BATCH_SIZE) {
flushUsageRecords().catch((err) =>
logger.error("Error flushing AI usage records:", err)
);
} else {
scheduleUsageFlush();
}
} catch (error) {
logger.error("Failed to record AI usage", { error });
}
}
+339
View File
@@ -0,0 +1,339 @@
import type { Request } from "express";
import { AI_CAPABILITIES, type AiCapability } from "@app/lib/aiCapabilities";
export { AI_CAPABILITIES, type AiCapability };
export type AiCapabilityRoute = {
method: "POST";
path: string;
};
export type AiProtocolFamily = "openai" | "anthropic" | "google" | "bedrock";
export type AiCapabilityDefinition = {
id: AiCapability;
protocolFamily: AiProtocolFamily;
routes: AiCapabilityRoute[];
extractModel: (req: Request) => string | undefined;
resolveUpstreamUrl: (
baseUrl: string,
req: Request,
model: string
) => string;
isStreaming: (req: Request, contentType: string) => boolean;
};
function bodyModel(req: Request): string | undefined {
return typeof req.body?.model === "string" ? req.body.model : undefined;
}
function paramModel(req: Request): string | undefined {
const model = req.params?.model;
return typeof model === "string" && model.length > 0 ? model : undefined;
}
export function joinUpstreamUrl(baseUrl: string, path: string): string {
const base = baseUrl.replace(/\/+$/, "");
let suffix = path.startsWith("/") ? path : `/${path}`;
let basePathname = "/";
try {
basePathname = new URL(base).pathname.replace(/\/+$/, "") || "/";
} catch {
// Fall through with "/" non-absolute bases are not expected in
// production, but keep joining usable for malformed input.
}
if (basePathname !== "/") {
const baseSegs = basePathname.split("/").filter(Boolean);
const pathSegs = suffix.split("/").filter(Boolean);
const max = Math.min(baseSegs.length, pathSegs.length);
let overlap = 0;
for (let n = max; n >= 1; n--) {
const baseSuffix = baseSegs.slice(-n);
const pathPrefix = pathSegs.slice(0, n);
if (baseSuffix.every((seg, i) => seg === pathPrefix[i])) {
overlap = n;
break;
}
}
if (overlap > 0) {
const remaining = pathSegs.slice(overlap);
suffix = remaining.length > 0 ? `/${remaining.join("/")}` : "/";
}
}
if (suffix === "/") {
return base;
}
return `${base}${suffix}`;
}
function pathFromRequest(req: Request): string {
const raw = req.originalUrl || req.url || req.path;
return raw.startsWith("/") ? raw : `/${raw}`;
}
function bodyRequestsStream(req: Request): boolean {
return req.body?.stream === true;
}
function contentTypeIsSse(contentType: string): boolean {
return contentType.includes("text/event-stream");
}
function contentTypeIsAmazonEventStream(contentType: string): boolean {
return contentType.includes("application/vnd.amazon.eventstream");
}
function pathIncludes(req: Request, fragment: string): boolean {
return pathFromRequest(req).includes(fragment);
}
function isBodyOrSseStreaming(req: Request, contentType: string): boolean {
return bodyRequestsStream(req) || contentTypeIsSse(contentType);
}
function isGeminiStyleStreaming(req: Request, contentType: string): boolean {
return (
pathIncludes(req, "streamGenerateContent") ||
pathIncludes(req, "alt=sse") ||
contentTypeIsSse(contentType)
);
}
export const AI_CAPABILITY_DEFS: Record<AiCapability, AiCapabilityDefinition> =
{
openai_chat: {
id: "openai_chat",
protocolFamily: "openai",
routes: [
{ method: "POST", path: "/v1/chat/completions" },
{ method: "POST", path: "/chat/completions" }
],
extractModel: bodyModel,
resolveUpstreamUrl: (base, req) =>
joinUpstreamUrl(base, pathFromRequest(req)),
isStreaming: isBodyOrSseStreaming
},
openai_responses: {
id: "openai_responses",
protocolFamily: "openai",
routes: [{ method: "POST", path: "/v1/responses" }],
extractModel: bodyModel,
resolveUpstreamUrl: (base, req) =>
joinUpstreamUrl(base, pathFromRequest(req)),
isStreaming: isBodyOrSseStreaming
},
anthropic_messages: {
id: "anthropic_messages",
protocolFamily: "anthropic",
routes: [{ method: "POST", path: "/v1/messages" }],
extractModel: bodyModel,
resolveUpstreamUrl: (base, req) =>
joinUpstreamUrl(base, pathFromRequest(req)),
isStreaming: isBodyOrSseStreaming
},
gemini_generate_content: {
id: "gemini_generate_content",
protocolFamily: "google",
routes: [
{
method: "POST",
path: "/v1beta/models/:model\\:generateContent"
},
{
method: "POST",
path: "/v1beta/models/:model\\:streamGenerateContent"
}
],
extractModel: paramModel,
resolveUpstreamUrl: (base, req) =>
joinUpstreamUrl(base, pathFromRequest(req)),
isStreaming: isGeminiStyleStreaming
},
google_generate_content: {
id: "google_generate_content",
protocolFamily: "google",
routes: [
{
method: "POST",
// Vertex publisher model generateContent
path: "/v1/projects/:project/locations/:location/publishers/:publisher/models/:model\\:generateContent"
},
{
method: "POST",
path: "/v1/projects/:project/locations/:location/publishers/:publisher/models/:model\\:streamGenerateContent"
}
],
extractModel: paramModel,
resolveUpstreamUrl: (base, req) =>
joinUpstreamUrl(base, pathFromRequest(req)),
isStreaming: isGeminiStyleStreaming
},
google_raw_predict: {
id: "google_raw_predict",
protocolFamily: "google",
routes: [
{
method: "POST",
path: "/v1/projects/:project/locations/:location/publishers/:publisher/models/:model\\:rawPredict"
},
{
method: "POST",
path: "/v1/projects/:project/locations/:location/publishers/:publisher/models/:model\\:streamRawPredict"
}
],
extractModel: paramModel,
resolveUpstreamUrl: (base, req) =>
joinUpstreamUrl(base, pathFromRequest(req)),
isStreaming: (req, contentType) =>
pathIncludes(req, "streamRawPredict") ||
pathIncludes(req, "alt=sse") ||
contentTypeIsSse(contentType)
},
bedrock_model_invoke: {
id: "bedrock_model_invoke",
protocolFamily: "bedrock",
routes: [
{ method: "POST", path: "/model/:model/invoke" },
{
method: "POST",
path: "/model/:model/invoke-with-response-stream"
}
],
extractModel: paramModel,
resolveUpstreamUrl: (base, req) =>
joinUpstreamUrl(base, pathFromRequest(req)),
isStreaming: (req, contentType) =>
pathIncludes(req, "invoke-with-response-stream") ||
contentTypeIsAmazonEventStream(contentType) ||
contentTypeIsSse(contentType)
},
bedrock_converse: {
id: "bedrock_converse",
protocolFamily: "bedrock",
routes: [
{ method: "POST", path: "/model/:model/converse" },
{ method: "POST", path: "/model/:model/converse-stream" }
],
extractModel: paramModel,
resolveUpstreamUrl: (base, req) =>
joinUpstreamUrl(base, pathFromRequest(req)),
isStreaming: (req, contentType) =>
pathIncludes(req, "converse-stream") ||
contentTypeIsAmazonEventStream(contentType) ||
contentTypeIsSse(contentType)
}
};
export function isAiCapability(value: unknown): value is AiCapability {
return (
typeof value === "string" &&
(AI_CAPABILITIES as readonly string[]).includes(value)
);
}
/**
* Convert an Express-style route path from AI_CAPABILITY_DEFS into a RegExp.
* Handles `:param` segments and escaped literal colons (`\:`).
*/
export function routePatternToRegExp(routePath: string): RegExp {
let pattern = "";
for (let i = 0; i < routePath.length; i++) {
const ch = routePath[i];
if (
ch === "\\" &&
i + 1 < routePath.length &&
routePath[i + 1] === ":"
) {
pattern += ":";
i++;
continue;
}
if (ch === ":") {
// Named param: consume until next / or end
i++;
while (
i < routePath.length &&
routePath[i] !== "/" &&
!(routePath[i] === "\\" && routePath[i + 1] === ":")
) {
i++;
}
i--; // loop will ++
pattern += "[^/]+";
continue;
}
// Escape regex special chars
if (/[.*+?^${}()|[\]\\]/.test(ch)) {
pattern += "\\" + ch;
} else {
pattern += ch;
}
}
return new RegExp(`^${pattern}$`);
}
export function resolveAiCapabilityFromPath(path: string): AiCapability | null {
const pathname = path.split("?")[0] || "/";
const normalized = pathname.startsWith("/") ? pathname : `/${pathname}`;
for (const def of Object.values(AI_CAPABILITY_DEFS)) {
for (const route of def.routes) {
if (routePatternToRegExp(route.path).test(normalized)) {
return def.id;
}
}
}
return null;
}
export function parseCapabilities(raw: unknown): AiCapability[] {
if (raw == null) {
return [];
}
let parsed: unknown = raw;
if (typeof raw === "string") {
const trimmed = raw.trim();
if (!trimmed) {
return [];
}
try {
parsed = JSON.parse(trimmed);
} catch {
return [];
}
}
if (!Array.isArray(parsed)) {
return [];
}
const out: AiCapability[] = [];
const seen = new Set<AiCapability>();
for (const item of parsed) {
if (isAiCapability(item) && !seen.has(item)) {
seen.add(item);
out.push(item);
}
}
return out;
}
export function serializeCapabilities(capabilities: AiCapability[]): string {
return JSON.stringify(capabilities);
}
export function providerHasCapability(
capabilities: AiCapability[] | string | null | undefined,
capability: AiCapability
): boolean {
const list =
typeof capabilities === "string" || capabilities == null
? parseCapabilities(capabilities)
: capabilities;
return list.includes(capability);
}
+139
View File
@@ -0,0 +1,139 @@
import {
AI_CAPABILITY_DEFS,
type AiCapability,
type AiProtocolFamily
} from "@server/lib/aiCapabilities";
import HttpCode from "@server/types/HttpCode";
export type ClientErrorResponse = {
statusCode?: number;
contentType?: string;
body: string;
};
export type AiCapabilityErrorKind =
| "authentication"
| "invalid_request"
| "not_found"
| "permission"
| "rate_limit"
| "internal";
const AUTH_MESSAGE = "Invalid API key provided.";
type KindFields = {
openaiType: string;
openaiCode: string | null;
anthropicType: string;
googleStatus: string;
};
const KIND_FIELDS: Record<AiCapabilityErrorKind, KindFields> = {
authentication: {
openaiType: "authentication_error",
openaiCode: "invalid_api_key",
anthropicType: "authentication_error",
googleStatus: "UNAUTHENTICATED"
},
invalid_request: {
openaiType: "invalid_request_error",
openaiCode: null,
anthropicType: "invalid_request_error",
googleStatus: "INVALID_ARGUMENT"
},
not_found: {
openaiType: "invalid_request_error",
openaiCode: null,
anthropicType: "not_found_error",
googleStatus: "NOT_FOUND"
},
permission: {
openaiType: "invalid_request_error",
openaiCode: null,
anthropicType: "permission_error",
googleStatus: "PERMISSION_DENIED"
},
rate_limit: {
openaiType: "rate_limit_error",
openaiCode: "rate_limit_exceeded",
anthropicType: "rate_limit_error",
googleStatus: "RESOURCE_EXHAUSTED"
},
internal: {
openaiType: "api_error",
openaiCode: null,
anthropicType: "api_error",
googleStatus: "INTERNAL"
}
};
function resolveProtocolFamily(
capability: AiCapability | null
): AiProtocolFamily {
if (capability == null) {
return "openai";
}
return AI_CAPABILITY_DEFS[capability].protocolFamily;
}
/**
* Build a protocol-native error body for the given capability.
* Message stays contextual; only the envelope/machine fields follow the
* capability's native API shape.
*/
export function buildAiCapabilityErrorBody(
capability: AiCapability | null,
kind: AiCapabilityErrorKind,
message: string,
httpStatus?: number
): Record<string, unknown> {
const family = resolveProtocolFamily(capability);
const fields = KIND_FIELDS[kind];
switch (family) {
case "openai":
return {
error: {
message,
type: fields.openaiType,
param: null,
code: fields.openaiCode
}
};
case "anthropic":
return {
type: "error",
error: {
type: fields.anthropicType,
message
}
};
case "google":
return {
error: {
code: httpStatus ?? HttpCode.BAD_REQUEST,
message,
status: fields.googleStatus
}
};
case "bedrock":
return { message };
}
}
export function buildInferenceAuthClientError(
capability: AiCapability | null
): ClientErrorResponse {
return {
statusCode: HttpCode.UNAUTHORIZED,
contentType: "application/json",
body: JSON.stringify(
buildAiCapabilityErrorBody(
capability,
"authentication",
AUTH_MESSAGE,
HttpCode.UNAUTHORIZED
)
)
};
}
+69
View File
@@ -0,0 +1,69 @@
import { createHash } from "crypto";
import config from "@server/lib/config";
export const AI_GATEWAY_TRUST_HEADER = "X-Pangolin-Ai-Gateway-Auth";
// Injected by the same Traefik trust middleware as AI_GATEWAY_TRUST_HEADER,
// but its value differs per router (public inference resource vs. private
// siteResource) so the gateway can tell which kind of resource a trusted
// request arrived on without re-deriving it from resourceId/siteResourceId.
export const AI_GATEWAY_RESOURCE_TYPE_HEADER =
"X-Pangolin-Ai-Gateway-Resource-Type";
export type AiGatewayResourceType = "resource" | "site-resource";
// Opt-in (server.enable_ai_gateway_client_ip_header): carries the client IP
// that Badger resolved at the Traefik hop, so it survives an intermediary
// proxy between Traefik and the AI gateway that overwrites
// X-Forwarded-For/X-Real-Ip instead of appending to them. Set by a
// disableForwardAuth Badger middleware instance (see getTraefikConfig.ts)
// on the site-resource inference router only, since that's the sole path
// that resolves request identity from the client IP.
export const AI_GATEWAY_CLIENT_IP_HEADER = "X-Pangolin-Client-Ip";
/**
* Derive a Traefik-injected trust token from the server secret.
* Traefik overwrites this header on inference routes so the AI gateway can
* trust Badger-injected Remote-* identity without re-validating credentials.
*/
export function deriveAiGatewayTrustToken(secret: string): string {
return createHash("sha256")
.update(`ai-gateway-trust:${secret}`)
.digest("hex");
}
export function getAiGatewayTrustToken(): string {
const secret = config.getRawConfig().server.secret;
if (!secret) {
throw new Error("Server secret is required for AI gateway trust token");
}
return deriveAiGatewayTrustToken(secret);
}
export function isAiGatewayTrustHeaderValid(
headers: Record<string, string | string[] | undefined> | undefined,
expectedToken?: string
): boolean {
if (!headers) {
return false;
}
const expected = expectedToken ?? getAiGatewayTrustToken();
const raw =
headers[AI_GATEWAY_TRUST_HEADER] ??
headers[AI_GATEWAY_TRUST_HEADER.toLowerCase()];
const value = Array.isArray(raw) ? raw[0] : raw;
return typeof value === "string" && value === expected;
}
export function getAiGatewayResourceType(
headers: Record<string, string | string[] | undefined> | undefined
): AiGatewayResourceType | null {
if (!headers) {
return null;
}
const raw =
headers[AI_GATEWAY_RESOURCE_TYPE_HEADER] ??
headers[AI_GATEWAY_RESOURCE_TYPE_HEADER.toLowerCase()];
const value = Array.isArray(raw) ? raw[0] : raw;
return value === "resource" || value === "site-resource" ? value : null;
}
+82
View File
@@ -0,0 +1,82 @@
import http from "node:http";
import https from "node:https";
import { Readable } from "node:stream";
type UpstreamFetchInit = {
method: string;
headers: Record<string, string>;
body?: string;
skipTlsVerification?: boolean;
signal?: AbortSignal;
};
const insecureHttpsAgent = new https.Agent({
rejectUnauthorized: false,
keepAlive: true
});
export function aiGatewayUpstreamFetch(
url: string,
init: UpstreamFetchInit
): Promise<Response> {
const parsed = new URL(url);
const isHttps = parsed.protocol === "https:";
const lib = isHttps ? https : http;
const agent =
isHttps && init.skipTlsVerification ? insecureHttpsAgent : undefined;
return new Promise((resolve, reject) => {
if (init.signal?.aborted) {
reject(init.signal.reason ?? new Error("Request aborted"));
return;
}
const req = lib.request(
url,
{
method: init.method,
headers: init.headers,
agent
},
(res) => {
const headers = new Headers();
for (const [key, value] of Object.entries(res.headers)) {
if (value === undefined) {
continue;
}
if (Array.isArray(value)) {
for (const entry of value) {
headers.append(key, entry);
}
} else {
headers.set(key, value);
}
}
const body = Readable.toWeb(res) as ReadableStream<Uint8Array>;
resolve(
new Response(body, {
status: res.statusCode ?? 502,
statusText: res.statusMessage,
headers
})
);
}
);
req.on("error", reject);
if (init.signal) {
const onAbort = () => req.destroy(init.signal!.reason);
init.signal.addEventListener("abort", onAbort, { once: true });
req.on("close", () =>
init.signal!.removeEventListener("abort", onAbort)
);
}
if (init.body !== undefined) {
req.write(init.body);
}
req.end();
});
}
+721
View File
@@ -0,0 +1,721 @@
import { and, eq, inArray } from "drizzle-orm";
import {
aiModels,
aiProviders,
db,
resourceAiModels,
resourceAiProviders,
siteResourceAiModels,
siteResourceAiProviders,
type Transaction
} from "@server/db";
import { z } from "zod";
type DbOrTrx = Transaction | typeof db;
export const modelListTypeSchema = z.enum(["allow", "block"]);
export type ModelListType = z.infer<typeof modelListTypeSchema>;
export const accessModeSchema = z.enum(["inherit", "select"]);
export type AccessMode = z.infer<typeof accessModeSchema>;
export const resourceAiProviderAttachmentSchema = z.strictObject({
providerId: z.number().int().positive(),
accessMode: accessModeSchema.optional().default("inherit"),
enabled: z.boolean().optional().default(true)
});
export type ResourceAiProviderInput = z.infer<
typeof resourceAiProviderAttachmentSchema
>;
export type ResourceAiProviderAttachment = {
providerId: number;
accessMode: AccessMode;
enabled: boolean;
};
export const resourceAiModelEntrySchema = z.strictObject({
modelId: z.number().int().positive(),
listType: modelListTypeSchema
});
export type ResourceAiModelEntry = z.infer<typeof resourceAiModelEntrySchema>;
export type InferenceFieldsError = {
error: string;
};
export function isInferenceFieldsError(
value: { error: string } | object
): value is InferenceFieldsError {
return "error" in value;
}
/**
* Resolve which allow/block patterns apply for an attachment.
* inherit provider lists; select resource-selected lists (replace).
*/
export function resolveEffectiveLists(input: {
accessMode: AccessMode;
providerAllows: string[];
providerBlocks: string[];
resourceAllows: string[];
resourceBlocks: string[];
}): { allows: string[]; blocks: string[] } {
if (input.accessMode === "select") {
return {
allows: input.resourceAllows,
blocks: input.resourceBlocks
};
}
return {
allows: input.providerAllows,
blocks: input.providerBlocks
};
}
function normalizeAttachments(
inputs: ResourceAiProviderInput[]
): ResourceAiProviderAttachment[] {
const byProviderId = new Map<
number,
{ accessMode: AccessMode; enabled: boolean }
>();
for (const input of inputs) {
byProviderId.set(input.providerId, {
accessMode: input.accessMode ?? "inherit",
enabled: input.enabled ?? true
});
}
return [...byProviderId.entries()].map(
([providerId, { accessMode, enabled }]) => ({
providerId,
accessMode,
enabled
})
);
}
/**
* Validate provider attachments for an org.
*/
export async function resolveProviderAttachments(input: {
orgId: string;
attachments: ResourceAiProviderInput[];
requireAtLeastOne: boolean;
}): Promise<ResourceAiProviderAttachment[] | InferenceFieldsError> {
const attachments = normalizeAttachments(input.attachments);
if (input.requireAtLeastOne && attachments.length === 0) {
return {
error: "At least one AI provider is required for inference-mode resources"
};
}
if (attachments.length === 0) {
return [];
}
const providerIds = attachments.map((a) => a.providerId);
const providers = await db
.select({
providerId: aiProviders.providerId,
orgId: aiProviders.orgId,
enabled: aiProviders.enabled
})
.from(aiProviders)
.where(
and(
inArray(aiProviders.providerId, providerIds),
eq(aiProviders.orgId, input.orgId)
)
);
if (providers.length !== providerIds.length) {
return {
error: "One or more AI providers were not found in this organization"
};
}
const disabled = providers.find((p) => !p.enabled);
if (disabled) {
return {
error: `AI provider with ID ${disabled.providerId} is disabled`
};
}
return attachments;
}
export async function assertInferenceModeAllowsProviderFields(input: {
mode: string;
hasProviderAttachments: boolean;
}): Promise<InferenceFieldsError | null> {
if (input.mode === "inference") {
return null;
}
if (input.hasProviderAttachments) {
return {
error: "AI providers can only be attached to inference-mode resources"
};
}
return null;
}
/**
* Attach providers to a resource. Inherit attachments use the provider lists
* as-is (resource model rows for those providers are pruned). Select
* attachments keep resource-selected allow/block subsets.
*/
export async function setPublicResourceAiProviders(
resourceId: number,
attachments: ResourceAiProviderAttachment[],
trx: DbOrTrx = db
): Promise<void> {
await trx
.delete(resourceAiProviders)
.where(eq(resourceAiProviders.resourceId, resourceId));
if (attachments.length > 0) {
await trx.insert(resourceAiProviders).values(
attachments.map((a) => ({
resourceId,
providerId: a.providerId,
accessMode: a.accessMode,
enabled: a.enabled
}))
);
}
await prunePublicResourceModelsToSelectProviders(
resourceId,
attachments,
trx
);
}
export async function setSiteResourceAiProviders(
siteResourceId: number,
attachments: ResourceAiProviderAttachment[],
trx: DbOrTrx = db
): Promise<void> {
await trx
.delete(siteResourceAiProviders)
.where(eq(siteResourceAiProviders.siteResourceId, siteResourceId));
if (attachments.length > 0) {
await trx.insert(siteResourceAiProviders).values(
attachments.map((a) => ({
siteResourceId,
providerId: a.providerId,
accessMode: a.accessMode,
enabled: a.enabled
}))
);
}
await pruneSiteResourceModelsToSelectProviders(
siteResourceId,
attachments,
trx
);
}
/**
* Keep resource model rows only for providers in select mode.
*/
async function prunePublicResourceModelsToSelectProviders(
resourceId: number,
attachments: ResourceAiProviderAttachment[],
trx: DbOrTrx
): Promise<void> {
const selectProviderIds = attachments
.filter((a) => a.accessMode === "select")
.map((a) => a.providerId);
if (selectProviderIds.length === 0) {
await trx
.delete(resourceAiModels)
.where(eq(resourceAiModels.resourceId, resourceId));
return;
}
const existing = await trx
.select({
modelId: resourceAiModels.modelId,
providerId: aiModels.providerId
})
.from(resourceAiModels)
.innerJoin(aiModels, eq(resourceAiModels.modelId, aiModels.modelId))
.where(eq(resourceAiModels.resourceId, resourceId));
const allowed = new Set(selectProviderIds);
const toRemove = existing
.filter((row) => !allowed.has(row.providerId))
.map((row) => row.modelId);
if (toRemove.length > 0) {
await trx
.delete(resourceAiModels)
.where(
and(
eq(resourceAiModels.resourceId, resourceId),
inArray(resourceAiModels.modelId, toRemove)
)
);
}
}
async function pruneSiteResourceModelsToSelectProviders(
siteResourceId: number,
attachments: ResourceAiProviderAttachment[],
trx: DbOrTrx
): Promise<void> {
const selectProviderIds = attachments
.filter((a) => a.accessMode === "select")
.map((a) => a.providerId);
if (selectProviderIds.length === 0) {
await trx
.delete(siteResourceAiModels)
.where(eq(siteResourceAiModels.siteResourceId, siteResourceId));
return;
}
const existing = await trx
.select({
modelId: siteResourceAiModels.modelId,
providerId: aiModels.providerId
})
.from(siteResourceAiModels)
.innerJoin(aiModels, eq(siteResourceAiModels.modelId, aiModels.modelId))
.where(eq(siteResourceAiModels.siteResourceId, siteResourceId));
const allowed = new Set(selectProviderIds);
const toRemove = existing
.filter((row) => !allowed.has(row.providerId))
.map((row) => row.modelId);
if (toRemove.length > 0) {
await trx
.delete(siteResourceAiModels)
.where(
and(
eq(siteResourceAiModels.siteResourceId, siteResourceId),
inArray(siteResourceAiModels.modelId, toRemove)
)
);
}
}
export async function clearPublicResourceAiConfig(
resourceId: number,
trx: DbOrTrx = db
): Promise<void> {
await trx
.delete(resourceAiModels)
.where(eq(resourceAiModels.resourceId, resourceId));
await trx
.delete(resourceAiProviders)
.where(eq(resourceAiProviders.resourceId, resourceId));
}
export async function clearSiteResourceAiConfig(
siteResourceId: number,
trx: DbOrTrx = db
): Promise<void> {
await trx
.delete(siteResourceAiModels)
.where(eq(siteResourceAiModels.siteResourceId, siteResourceId));
await trx
.delete(siteResourceAiProviders)
.where(eq(siteResourceAiProviders.siteResourceId, siteResourceId));
}
export async function listPublicResourceAiProviders(resourceId: number) {
return db
.select({
providerId: resourceAiProviders.providerId,
niceId: aiProviders.niceId,
name: aiProviders.name,
type: aiProviders.type,
enabled: resourceAiProviders.enabled,
providerEnabled: aiProviders.enabled,
accessMode: resourceAiProviders.accessMode
})
.from(resourceAiProviders)
.innerJoin(
aiProviders,
eq(resourceAiProviders.providerId, aiProviders.providerId)
)
.where(eq(resourceAiProviders.resourceId, resourceId));
}
export async function listSiteResourceAiProviders(siteResourceId: number) {
return db
.select({
providerId: siteResourceAiProviders.providerId,
niceId: aiProviders.niceId,
name: aiProviders.name,
type: aiProviders.type,
enabled: siteResourceAiProviders.enabled,
providerEnabled: aiProviders.enabled,
accessMode: siteResourceAiProviders.accessMode
})
.from(siteResourceAiProviders)
.innerJoin(
aiProviders,
eq(siteResourceAiProviders.providerId, aiProviders.providerId)
)
.where(eq(siteResourceAiProviders.siteResourceId, siteResourceId));
}
export type EffectiveAllowModel = {
modelId: number;
modelKey: string;
name: string;
providerId: number;
providerName: string;
};
export async function listEffectiveAllowModels(options: {
resourceId?: number;
siteResourceId?: number;
}): Promise<EffectiveAllowModel[]> {
if (
options.resourceId === undefined &&
options.siteResourceId === undefined
) {
return [];
}
const attachments =
options.resourceId !== undefined
? await listPublicResourceAiProviders(options.resourceId)
: await listSiteResourceAiProviders(options.siteResourceId!);
const activeAttachments = attachments.filter(
(a) => a.enabled && a.providerEnabled
);
if (activeAttachments.length === 0) {
return [];
}
const inheritProviderIds = activeAttachments
.filter((a) => a.accessMode === "inherit")
.map((a) => a.providerId);
const selectProviderIds = activeAttachments
.filter((a) => a.accessMode === "select")
.map((a) => a.providerId);
const providerNameById = new Map(
activeAttachments.map((a) => [a.providerId, a.name] as const)
);
const models: EffectiveAllowModel[] = [];
if (inheritProviderIds.length > 0) {
const rows = await db
.select({
modelId: aiModels.modelId,
modelKey: aiModels.modelKey,
name: aiModels.name,
providerId: aiModels.providerId
})
.from(aiModels)
.where(
and(
inArray(aiModels.providerId, inheritProviderIds),
eq(aiModels.enabled, true),
eq(aiModels.listType, "allow")
)
);
for (const row of rows) {
models.push({
...row,
providerName: providerNameById.get(row.providerId) ?? ""
});
}
}
if (selectProviderIds.length > 0) {
if (options.resourceId !== undefined) {
const rows = await db
.select({
modelId: aiModels.modelId,
modelKey: aiModels.modelKey,
name: aiModels.name,
providerId: aiModels.providerId
})
.from(resourceAiModels)
.innerJoin(
aiModels,
eq(resourceAiModels.modelId, aiModels.modelId)
)
.where(
and(
eq(resourceAiModels.resourceId, options.resourceId),
inArray(aiModels.providerId, selectProviderIds),
eq(resourceAiModels.listType, "allow"),
eq(aiModels.enabled, true)
)
);
for (const row of rows) {
models.push({
...row,
providerName: providerNameById.get(row.providerId) ?? ""
});
}
} else if (options.siteResourceId !== undefined) {
const rows = await db
.select({
modelId: aiModels.modelId,
modelKey: aiModels.modelKey,
name: aiModels.name,
providerId: aiModels.providerId
})
.from(siteResourceAiModels)
.innerJoin(
aiModels,
eq(siteResourceAiModels.modelId, aiModels.modelId)
)
.where(
and(
eq(
siteResourceAiModels.siteResourceId,
options.siteResourceId
),
inArray(aiModels.providerId, selectProviderIds),
eq(siteResourceAiModels.listType, "allow"),
eq(aiModels.enabled, true)
)
);
for (const row of rows) {
models.push({
...row,
providerName: providerNameById.get(row.providerId) ?? ""
});
}
}
}
models.sort((a, b) => {
const byProvider = a.providerName.localeCompare(
b.providerName,
undefined,
{
sensitivity: "base"
}
);
if (byProvider !== 0) {
return byProvider;
}
return a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
});
return models;
}
/**
* Model list APIs require an inference resource with at least one select-mode
* attached provider.
*/
export async function assertPublicModelListApiEligible(resource: {
resourceId: number;
mode: string;
}): Promise<string | null> {
if (resource.mode !== "inference") {
return "AI model lists are only supported on inference-mode resources";
}
const [row] = await db
.select({ providerId: resourceAiProviders.providerId })
.from(resourceAiProviders)
.where(
and(
eq(resourceAiProviders.resourceId, resource.resourceId),
eq(resourceAiProviders.accessMode, "select")
)
)
.limit(1);
if (!row) {
return "Set at least one attached AI provider to select mode before managing model lists";
}
return null;
}
export async function assertSiteModelListApiEligible(siteResource: {
siteResourceId: number;
mode: string;
}): Promise<string | null> {
if (siteResource.mode !== "inference") {
return "AI model lists are only supported on inference-mode resources";
}
const [row] = await db
.select({ providerId: siteResourceAiProviders.providerId })
.from(siteResourceAiProviders)
.where(
and(
eq(
siteResourceAiProviders.siteResourceId,
siteResource.siteResourceId
),
eq(siteResourceAiProviders.accessMode, "select")
)
)
.limit(1);
if (!row) {
return "Set at least one attached AI provider to select mode before managing model lists";
}
return null;
}
/**
* Resource model entries must belong to select-mode attached providers, and
* listType must match the provider catalog entry (allowallow, blockblock).
*/
export async function assertPublicResourceModelEntriesValid(input: {
orgId: string;
resourceId: number;
models: ResourceAiModelEntry[];
}): Promise<string | null> {
const uniqueModels = dedupeModelEntries(input.models);
if (uniqueModels.length === 0) {
return null;
}
const attachments = await db
.select({
providerId: resourceAiProviders.providerId,
accessMode: resourceAiProviders.accessMode,
enabled: resourceAiProviders.enabled
})
.from(resourceAiProviders)
.innerJoin(
aiProviders,
eq(resourceAiProviders.providerId, aiProviders.providerId)
)
.where(
and(
eq(resourceAiProviders.resourceId, input.resourceId),
eq(aiProviders.orgId, input.orgId)
)
);
return assertModelEntriesValid({
orgId: input.orgId,
modelEntries: uniqueModels,
attachments,
resourceLabel: "resource"
});
}
export async function assertSiteResourceModelEntriesValid(input: {
orgId: string;
siteResourceId: number;
models: ResourceAiModelEntry[];
}): Promise<string | null> {
const uniqueModels = dedupeModelEntries(input.models);
if (uniqueModels.length === 0) {
return null;
}
const attachments = await db
.select({
providerId: siteResourceAiProviders.providerId,
accessMode: siteResourceAiProviders.accessMode,
enabled: siteResourceAiProviders.enabled
})
.from(siteResourceAiProviders)
.innerJoin(
aiProviders,
eq(siteResourceAiProviders.providerId, aiProviders.providerId)
)
.where(
and(
eq(
siteResourceAiProviders.siteResourceId,
input.siteResourceId
),
eq(aiProviders.orgId, input.orgId)
)
);
return assertModelEntriesValid({
orgId: input.orgId,
modelEntries: uniqueModels,
attachments,
resourceLabel: "site resource"
});
}
function dedupeModelEntries(
models: ResourceAiModelEntry[]
): ResourceAiModelEntry[] {
const byModelId = new Map(
models.map((m) => [m.modelId, m.listType] as const)
);
return [...byModelId.entries()].map(([modelId, listType]) => ({
modelId,
listType
}));
}
async function assertModelEntriesValid(input: {
orgId: string;
modelEntries: ResourceAiModelEntry[];
attachments: ResourceAiProviderAttachment[];
resourceLabel: string;
}): Promise<string | null> {
const selectProviderIds = input.attachments
.filter((a) => a.accessMode === "select")
.map((a) => a.providerId);
if (selectProviderIds.length === 0) {
return "Set at least one attached AI provider to select mode before managing model lists";
}
const modelIds = input.modelEntries.map((m) => m.modelId);
const catalogRows = await db
.select({
modelId: aiModels.modelId,
listType: aiModels.listType,
providerId: aiModels.providerId,
enabled: aiModels.enabled
})
.from(aiModels)
.innerJoin(aiProviders, eq(aiModels.providerId, aiProviders.providerId))
.where(
and(
inArray(aiModels.modelId, modelIds),
inArray(aiModels.providerId, selectProviderIds),
eq(aiProviders.orgId, input.orgId)
)
);
if (catalogRows.length !== modelIds.length) {
return `One or more model IDs do not exist or do not belong to a select-mode provider on this ${input.resourceLabel}`;
}
const catalogById = new Map(catalogRows.map((row) => [row.modelId, row]));
for (const entry of input.modelEntries) {
const catalog = catalogById.get(entry.modelId);
if (!catalog) {
return `One or more model IDs do not exist or do not belong to a select-mode provider on this ${input.resourceLabel}`;
}
if (catalog.listType !== entry.listType) {
return `Model ${entry.modelId} must use listType "${catalog.listType}" to match the provider catalog entry`;
}
if (!catalog.enabled) {
return `Model ${entry.modelId} is disabled on its provider`;
}
}
return null;
}
+538
View File
@@ -0,0 +1,538 @@
import type { AiCapability } from "@server/lib/aiCapabilities";
import { sseDataFrames, tryParseJson } from "@server/lib/aiUsageExtraction";
import logger from "@server/logger";
// Uniform, capability-agnostic representation of a chat message, used so
// the AI session log can be searched/displayed the same way regardless of
// which provider/capability produced it. Content is flattened to plain text
// - non-text parts (images, tool calls/results) are rendered as readable
// placeholders rather than preserved as structured data, which is enough for
// a transcript-style replay view without a per-capability renderer.
export type NormalizedRole = "system" | "user" | "assistant" | "tool";
export type NormalizedAiMessage = {
role: NormalizedRole;
content: string;
};
function normalizeRole(role: unknown): NormalizedRole {
if (
role === "system" ||
role === "user" ||
role === "assistant" ||
role === "tool"
) {
return role;
}
if (role === "model") return "assistant"; // Gemini
if (role === "function") return "tool"; // OpenAI legacy function role
return "user";
}
function safeJsonStringify(value: unknown): string {
try {
return JSON.stringify(value ?? {});
} catch {
return "";
}
}
/**
* Flattens one message "part"/"block" (OpenAI content parts, Anthropic
* content blocks, Gemini parts, Bedrock converse content blocks - they all
* follow the same rough shape) into readable text.
*/
function flattenContentPart(part: unknown): string {
if (typeof part === "string") return part;
if (part == null || typeof part !== "object") return "";
const p = part as Record<string, unknown>;
if (typeof p.text === "string") return p.text;
if (
p.type === "image_url" ||
p.type === "image" ||
p.type === "input_image" ||
p.type === "output_image" ||
"inlineData" in p
) {
return "[image]";
}
// Anthropic-style tool_use / tool_result blocks
if (p.type === "tool_use") {
const name = typeof p.name === "string" ? p.name : "tool";
return `[tool_call: ${name}(${safeJsonStringify(p.input)})]`;
}
if (p.type === "tool_result") {
const content = p.content;
const text =
typeof content === "string"
? content
: Array.isArray(content)
? flattenContentParts(content)
: "";
return `[tool_result: ${text}]`;
}
// Gemini-style functionCall / functionResponse parts
if (p.functionCall && typeof p.functionCall === "object") {
const fc = p.functionCall as Record<string, unknown>;
return `[tool_call: ${fc.name}(${safeJsonStringify(fc.args)})]`;
}
if (p.functionResponse && typeof p.functionResponse === "object") {
const fr = p.functionResponse as Record<string, unknown>;
return `[tool_result: ${fr.name}(${safeJsonStringify(fr.response)})]`;
}
// Bedrock converse-style toolUse / toolResult content blocks
if (p.toolUse && typeof p.toolUse === "object") {
const tu = p.toolUse as Record<string, unknown>;
return `[tool_call: ${tu.name}(${safeJsonStringify(tu.input)})]`;
}
if (p.toolResult && typeof p.toolResult === "object") {
const tr = p.toolResult as Record<string, unknown>;
const content = tr.content;
const text = Array.isArray(content) ? flattenContentParts(content) : "";
return `[tool_result: ${text}]`;
}
return "";
}
function flattenContentParts(parts: unknown[]): string {
return parts.map(flattenContentPart).join("");
}
function flattenContent(content: unknown): string {
if (typeof content === "string") return content;
if (Array.isArray(content)) return flattenContentParts(content);
return "";
}
/**
* Best-effort scan for every `"text":"..."` JSON string value in raw text,
* concatenated in order. Fallback for streaming formats we can't fully parse
* as JSON/SSE (Gemini's array-JSON stream, Bedrock's binary event-stream
* framing) - same spirit as aiUsageExtraction's scanNumericFields.
*/
function scanTextFragments(text: string): string {
const out: string[] = [];
const re = /"text"\s*:\s*"((?:[^"\\]|\\.)*)"/g;
let match: RegExpExecArray | null;
while ((match = re.exec(text)) !== null) {
try {
out.push(JSON.parse(`"${match[1]}"`));
} catch {
out.push(match[1]);
}
}
return out.join("");
}
// ---------------------------------------------------------------------------
// Request (input) normalizers - operate on the already-parsed outbound body.
// ---------------------------------------------------------------------------
function normalizeOpenAiChatRequest(body: any): NormalizedAiMessage[] {
const messages = Array.isArray(body?.messages) ? body.messages : [];
return messages.map((m: any) => ({
role: normalizeRole(m?.role),
content: flattenContent(m?.content)
}));
}
function normalizeOpenAiResponsesRequest(body: any): NormalizedAiMessage[] {
const out: NormalizedAiMessage[] = [];
if (typeof body?.instructions === "string" && body.instructions) {
out.push({ role: "system", content: body.instructions });
}
const input = body?.input;
if (typeof input === "string") {
out.push({ role: "user", content: input });
} else if (Array.isArray(input)) {
for (const item of input) {
if (item?.role) {
out.push({
role: normalizeRole(item.role),
content: flattenContent(item.content)
});
} else if (typeof item?.type === "string") {
out.push({ role: "tool", content: `[${item.type}]` });
}
}
}
return out;
}
function normalizeAnthropicRequest(body: any): NormalizedAiMessage[] {
const out: NormalizedAiMessage[] = [];
if (body?.system) {
const sys = flattenContent(body.system);
if (sys) out.push({ role: "system", content: sys });
}
const messages = Array.isArray(body?.messages) ? body.messages : [];
for (const m of messages) {
out.push({
role: normalizeRole(m?.role),
content: flattenContent(m?.content)
});
}
return out;
}
function normalizeGeminiRequest(body: any): NormalizedAiMessage[] {
const out: NormalizedAiMessage[] = [];
const sysParts = body?.systemInstruction?.parts;
if (Array.isArray(sysParts)) {
const text = flattenContentParts(sysParts);
if (text) out.push({ role: "system", content: text });
}
const contents = Array.isArray(body?.contents) ? body.contents : [];
for (const c of contents) {
out.push({
role: normalizeRole(c?.role),
content: Array.isArray(c?.parts) ? flattenContentParts(c.parts) : ""
});
}
return out;
}
function normalizeBedrockConverseRequest(body: any): NormalizedAiMessage[] {
const out: NormalizedAiMessage[] = [];
if (Array.isArray(body?.system)) {
const text = flattenContentParts(body.system);
if (text) out.push({ role: "system", content: text });
}
const messages = Array.isArray(body?.messages) ? body.messages : [];
for (const m of messages) {
out.push({
role: normalizeRole(m?.role),
content: Array.isArray(m?.content)
? flattenContentParts(m.content)
: ""
});
}
return out;
}
/**
* bedrock_model_invoke and google_raw_predict are passthroughs - the body
* shape depends entirely on the underlying model, not the capability. Try
* the two shapes we're most likely to see (Anthropic Claude, then plain
* OpenAI-style) and give up otherwise, same fallback spirit
* aiUsageExtraction.ts uses for these two capabilities' usage extraction.
*/
function normalizeBestEffortRequest(body: any): NormalizedAiMessage[] | null {
if (!Array.isArray(body?.messages)) return null;
const looksAnthropicShaped = body.messages.some((m: any) =>
Array.isArray(m?.content)
);
return looksAnthropicShaped
? normalizeAnthropicRequest(body)
: normalizeOpenAiChatRequest(body);
}
// ---------------------------------------------------------------------------
// Response (output) normalizers - operate on the raw response text, which
// may be a single JSON document (non-streaming) or provider-framed streaming
// text (SSE `data:` frames, a JSON-array stream, or binary event-stream
// framing with JSON payloads embedded in it).
// ---------------------------------------------------------------------------
function normalizeOpenAiChatResponse(
text: string,
isStream: boolean
): NormalizedAiMessage[] | null {
if (isStream) {
let role: unknown = "assistant";
let content = "";
let found = false;
for (const frame of sseDataFrames(text)) {
const delta = tryParseJson(frame)?.choices?.[0]?.delta;
if (!delta) continue;
found = true;
if (typeof delta.role === "string") role = delta.role;
if (typeof delta.content === "string") content += delta.content;
}
return found ? [{ role: normalizeRole(role), content }] : null;
}
const message = tryParseJson(text)?.choices?.[0]?.message;
if (!message) return null;
return [
{
role: normalizeRole(message.role),
content: flattenContent(message.content)
}
];
}
function extractOpenAiResponsesOutputText(response: any): string | null {
if (typeof response?.output_text === "string") return response.output_text;
const output = Array.isArray(response?.output) ? response.output : [];
const pieces: string[] = [];
for (const item of output) {
if (item?.type === "message" && Array.isArray(item.content)) {
pieces.push(flattenContentParts(item.content));
}
}
return pieces.length > 0 ? pieces.join("") : null;
}
function normalizeOpenAiResponsesResponse(
text: string,
isStream: boolean
): NormalizedAiMessage[] | null {
if (isStream) {
let content = "";
let found = false;
for (const frame of sseDataFrames(text)) {
const parsed = tryParseJson(frame);
if (!parsed) continue;
if (
parsed.type === "response.output_text.delta" &&
typeof parsed.delta === "string"
) {
content += parsed.delta;
found = true;
} else if (
parsed.type === "response.completed" &&
parsed.response
) {
const outputText = extractOpenAiResponsesOutputText(
parsed.response
);
if (outputText != null) {
content = outputText;
found = true;
}
}
}
return found ? [{ role: "assistant", content }] : null;
}
const parsed = tryParseJson(text);
const outputText = extractOpenAiResponsesOutputText(
parsed?.response ?? parsed
);
return outputText != null
? [{ role: "assistant", content: outputText }]
: null;
}
function normalizeAnthropicResponse(
text: string,
isStream: boolean
): NormalizedAiMessage[] | null {
if (isStream) {
let role: unknown = "assistant";
let content = "";
let found = false;
for (const frame of sseDataFrames(text)) {
const parsed = tryParseJson(frame);
if (!parsed) continue;
if (parsed.type === "message_start" && parsed.message?.role) {
role = parsed.message.role;
}
if (
parsed.type === "content_block_start" &&
parsed.content_block?.type === "tool_use"
) {
const name = parsed.content_block.name ?? "tool";
content += `[tool_call: ${name}]`;
found = true;
}
if (
parsed.type === "content_block_delta" &&
typeof parsed.delta?.text === "string"
) {
content += parsed.delta.text;
found = true;
}
}
return found ? [{ role: normalizeRole(role), content }] : null;
}
const parsed = tryParseJson(text);
if (!parsed || !Array.isArray(parsed.content)) return null;
return [
{
role: normalizeRole(parsed.role ?? "assistant"),
content: flattenContentParts(parsed.content)
}
];
}
function geminiCandidateParts(node: any): string {
const parts = node?.candidates?.[0]?.content?.parts;
return Array.isArray(parts) ? flattenContentParts(parts) : "";
}
function normalizeGeminiResponse(
text: string,
_isStream: boolean
): NormalizedAiMessage[] | null {
const frames = sseDataFrames(text);
let content = "";
let role: unknown = "model";
let found = false;
if (frames.length > 0) {
for (const frame of frames) {
const parsed = tryParseJson(frame);
const piece = geminiCandidateParts(parsed);
if (piece) {
content += piece;
found = true;
}
const r = parsed?.candidates?.[0]?.content?.role;
if (r) role = r;
}
} else {
const parsed = tryParseJson(text);
if (Array.isArray(parsed)) {
for (const chunk of parsed) {
const piece = geminiCandidateParts(chunk);
if (piece) {
content += piece;
found = true;
}
const r = chunk?.candidates?.[0]?.content?.role;
if (r) role = r;
}
} else if (parsed) {
const piece = geminiCandidateParts(parsed);
if (piece) {
content = piece;
found = true;
}
const r = parsed?.candidates?.[0]?.content?.role;
if (r) role = r;
}
}
if (!found) {
const scanned = scanTextFragments(text);
return scanned
? [{ role: normalizeRole(role), content: scanned }]
: null;
}
return [{ role: normalizeRole(role), content }];
}
function normalizeBedrockConverseResponse(
text: string,
isStream: boolean
): NormalizedAiMessage[] | null {
if (!isStream) {
const message = tryParseJson(text)?.output?.message;
if (!message) return null;
return [
{
role: normalizeRole(message.role ?? "assistant"),
content: Array.isArray(message.content)
? flattenContentParts(message.content)
: ""
}
];
}
// converse-stream uses AWS's binary event-stream framing, but the JSON
// payload of each event survives intact inside it (same assumption
// aiUsageExtraction.ts makes for usage) - scan for the text pieces.
const scanned = scanTextFragments(text);
return scanned ? [{ role: "assistant", content: scanned }] : null;
}
function normalizeBedrockModelInvokeResponse(
text: string,
isStream: boolean
): NormalizedAiMessage[] | null {
const anthropicStyle = normalizeAnthropicResponse(text, isStream);
if (anthropicStyle) return anthropicStyle;
const scanned = scanTextFragments(text);
return scanned ? [{ role: "assistant", content: scanned }] : null;
}
function normalizeGoogleRawPredictResponse(
text: string,
isStream: boolean
): NormalizedAiMessage[] | null {
const anthropicStyle = normalizeAnthropicResponse(text, isStream);
if (anthropicStyle) return anthropicStyle;
const scanned = scanTextFragments(text);
return scanned ? [{ role: "assistant", content: scanned }] : null;
}
const REQUEST_NORMALIZERS: Record<
AiCapability,
(body: any) => NormalizedAiMessage[] | null
> = {
openai_chat: normalizeOpenAiChatRequest,
openai_responses: normalizeOpenAiResponsesRequest,
anthropic_messages: normalizeAnthropicRequest,
gemini_generate_content: normalizeGeminiRequest,
google_generate_content: normalizeGeminiRequest,
google_raw_predict: normalizeBestEffortRequest,
bedrock_model_invoke: normalizeBestEffortRequest,
bedrock_converse: normalizeBedrockConverseRequest
};
const RESPONSE_NORMALIZERS: Record<
AiCapability,
(text: string, isStream: boolean) => NormalizedAiMessage[] | null
> = {
openai_chat: normalizeOpenAiChatResponse,
openai_responses: normalizeOpenAiResponsesResponse,
anthropic_messages: normalizeAnthropicResponse,
gemini_generate_content: normalizeGeminiResponse,
google_generate_content: normalizeGeminiResponse,
google_raw_predict: normalizeGoogleRawPredictResponse,
bedrock_model_invoke: normalizeBedrockModelInvokeResponse,
bedrock_converse: normalizeBedrockConverseResponse
};
/**
* Normalizes an outbound AI gateway request body into a uniform message
* transcript, regardless of capability/provider. Returns null if the body
* doesn't contain any recognizable messages (or parsing failed) - callers
* should fall back to showing the raw request body.
*/
export function normalizeAiRequest(
capability: AiCapability,
body: unknown
): NormalizedAiMessage[] | null {
try {
const result = REQUEST_NORMALIZERS[capability](body);
return result && result.length > 0 ? result : null;
} catch (error) {
logger.debug("Failed to normalize AI request messages", {
capability,
error
});
return null;
}
}
/**
* Normalizes a completed (non-streaming or fully-accumulated streaming) AI
* gateway response into a uniform message transcript. Returns null if
* nothing recognizable could be extracted - callers should fall back to
* showing the raw response body.
*/
export function normalizeAiResponse(
capability: AiCapability,
responseText: string,
isStream: boolean
): NormalizedAiMessage[] | null {
try {
const result = RESPONSE_NORMALIZERS[capability](responseText, isStream);
return result && result.length > 0 ? result : null;
} catch (error) {
logger.debug("Failed to normalize AI response messages", {
capability,
error
});
return null;
}
}
+323
View File
@@ -0,0 +1,323 @@
import fs from "node:fs";
import axios from "axios";
import { z } from "zod";
import config from "@server/lib/config";
import logger from "@server/logger";
import type { AiProviderType } from "@server/lib/aiProviderDefaults";
export const CATALOG_PROVIDERS = [
"openai",
"anthropic",
"gemini",
"vertex",
"azure",
"bedrock"
] as const;
export type CatalogProvider = (typeof CATALOG_PROVIDERS)[number];
const CATALOG_PROVIDER_SET = new Set<string>(CATALOG_PROVIDERS);
// Each of our provider types maps to at most one catalog provider. Provider
// types that proxy arbitrary underlying models (openRouter, vercelAiGateway,
// custom) have no mapping.
const PROVIDER_CATALOG_MAP: Record<
Exclude<AiProviderType, "custom">,
CatalogProvider | null
> = {
openai: "openai",
anthropic: "anthropic",
googleGemini: "gemini",
vertexAi: "vertex",
bedrock: "bedrock",
microsoftFoundry: "azure",
openRouter: null,
vercelAiGateway: null
};
export function getCatalogProviderForType(
type: AiProviderType
): CatalogProvider | null {
if (type === "custom") {
return null;
}
return PROVIDER_CATALOG_MAP[type];
}
export type AiModelCatalogEntry = {
provider: CatalogProvider;
model: string;
pricing: {
in: number | null;
out: number | null;
cache: number | null;
reasoning: number | null;
};
};
const catalogEntrySchema = z.object({
model: z.string(),
provider: z.string(),
pricing: z
.object({
in: z.number().nullable().optional(),
out: z.number().nullable().optional(),
cache: z.number().nullable().optional(),
reasoning: z.number().nullable().optional()
})
.optional()
});
const catalogFileSchema = z.object({
data: z.array(catalogEntrySchema).optional().default([])
});
type RawCatalogEntry = z.infer<typeof catalogEntrySchema>;
function normalizeCatalogProvider(raw: string): CatalogProvider | null {
if (CATALOG_PROVIDER_SET.has(raw)) {
return raw as CatalogProvider;
}
if (raw.startsWith("bedrock")) {
return "bedrock";
}
if (raw.startsWith("vertex")) {
return "vertex";
}
if (raw.startsWith("azure")) {
return "azure";
}
return null;
}
function normalizeEntry(raw: RawCatalogEntry): AiModelCatalogEntry | null {
const provider = normalizeCatalogProvider(raw.provider);
if (!provider) {
return null;
}
if (!raw.model) {
return null;
}
return {
provider,
model: raw.model,
pricing: {
in: raw.pricing?.in ?? null,
out: raw.pricing?.out ?? null,
cache: raw.pricing?.cache ?? null,
reasoning: raw.pricing?.reasoning ?? null
}
};
}
function providerKey(provider: CatalogProvider, key: string): string {
return `${provider}\0${key}`;
}
export class AiModelCatalog {
private entries: AiModelCatalogEntry[] = [];
private byProvider = new Map<CatalogProvider, AiModelCatalogEntry[]>();
private byProviderAndKey = new Map<string, AiModelCatalogEntry>();
private byKey = new Map<string, AiModelCatalogEntry[]>();
private refreshTimer: NodeJS.Timeout | null = null;
/**
* Loads the catalog into memory and schedules periodic background refreshes.
* Call once at server startup.
*/
async init(): Promise<void> {
await this.refresh();
this.scheduleNextRefresh();
}
/** Exact lookup by catalog provider and model key. */
get(
provider: CatalogProvider,
key: string
): AiModelCatalogEntry | undefined {
return this.byProviderAndKey.get(providerKey(provider, key));
}
/** All models for a catalog provider. */
list(provider: CatalogProvider): AiModelCatalogEntry[] {
return this.byProvider.get(provider) ?? [];
}
/** All catalog entries that share a model key, across providers. */
listByKey(key: string): AiModelCatalogEntry[] {
return this.byKey.get(key) ?? [];
}
/** Full in-memory catalog. */
getAll(): AiModelCatalogEntry[] {
return this.entries;
}
private setEntries(entries: AiModelCatalogEntry[]): void {
const byProvider = new Map<CatalogProvider, AiModelCatalogEntry[]>();
const byProviderAndKey = new Map<string, AiModelCatalogEntry>();
const byKey = new Map<string, AiModelCatalogEntry[]>();
for (const entry of entries) {
const list = byProvider.get(entry.provider) ?? [];
list.push(entry);
byProvider.set(entry.provider, list);
const mapKey = providerKey(entry.provider, entry.model);
if (!byProviderAndKey.has(mapKey)) {
byProviderAndKey.set(mapKey, entry);
}
const keyList = byKey.get(entry.model) ?? [];
keyList.push(entry);
byKey.set(entry.model, keyList);
}
this.entries = entries;
this.byProvider = byProvider;
this.byProviderAndKey = byProviderAndKey;
this.byKey = byKey;
}
private async fetchFromFile(
filePath: string
): Promise<AiModelCatalogEntry[] | null> {
try {
if (!fs.existsSync(filePath)) {
logger.warn(
`AI model catalog file not found at ${filePath}; cost calculation will fall back to unknown pricing`
);
return null;
}
const raw = fs.readFileSync(filePath, "utf-8");
const result = catalogFileSchema.safeParse(JSON.parse(raw));
if (!result.success) {
logger.warn(
`AI model catalog file at ${filePath} failed validation: ${result.error.message}`
);
return null;
}
return result.data.data
.map(normalizeEntry)
.filter((e): e is AiModelCatalogEntry => e != null);
} catch (error) {
logger.warn("Failed to read AI model catalog file", { error });
return null;
}
}
private async fetchFromUpstream(
upstreamUrl: string
): Promise<AiModelCatalogEntry[] | null> {
try {
const res = await axios.get(upstreamUrl, { timeout: 15_000 });
const result = catalogFileSchema.safeParse(res.data);
if (!result.success) {
logger.warn(
`AI model catalog response from ${upstreamUrl} failed validation: ${result.error.message}`
);
return null;
}
return result.data.data
.map(normalizeEntry)
.filter((e): e is AiModelCatalogEntry => e != null);
} catch (error: any) {
logger.warn(
`Failed to fetch AI model catalog from ${upstreamUrl}: ${error.message || error}`
);
return null;
}
}
private async refresh(): Promise<void> {
const { file, merge_file, upstream_url } =
config.getRawConfig().ai.model_catalog;
const fetched = file
? await this.fetchFromFile(file)
: await this.fetchFromUpstream(upstream_url);
if (!fetched) {
logger.debug(
"AI model catalog refresh failed; keeping previously loaded catalog in memory"
);
return;
}
let merged = fetched;
if (merge_file) {
const mergeEntries = await this.fetchFromFile(merge_file);
if (mergeEntries) {
// Entries from the base catalog take precedence; the merge
// file only adds models not already present.
merged = [...fetched, ...mergeEntries];
}
}
this.setEntries(merged);
logger.debug(
`AI model catalog refreshed: ${this.entries.length} models loaded`
);
}
private scheduleNextRefresh(): void {
const { refresh_interval_min_hours, refresh_interval_max_hours } =
config.getRawConfig().ai.model_catalog;
// Jittered rather than fixed so that many self-hosted instances don't
// all hit the upstream catalog endpoint at the same moment.
const minMs = refresh_interval_min_hours * 60 * 60 * 1000;
const maxMs = refresh_interval_max_hours * 60 * 60 * 1000;
const delayMs = minMs + Math.random() * Math.max(0, maxMs - minMs);
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
}
this.refreshTimer = setTimeout(async () => {
await this.refresh();
this.scheduleNextRefresh();
}, delayMs);
}
}
export const aiModelCatalog = new AiModelCatalog();
export function listCatalogModelsForType(
type: AiProviderType,
query?: string
): { model: string }[] {
const catalogProvider = getCatalogProviderForType(type);
let models = catalogProvider
? aiModelCatalog.list(catalogProvider).map((entry) => ({
model: entry.model
}))
: [];
if (query) {
const q = query.toLowerCase();
models = models.filter((m) => m.model.toLowerCase().includes(q));
}
const seen = new Set<string>();
models = models.filter((m) => {
if (seen.has(m.model)) {
return false;
}
seen.add(m.model);
return true;
});
models.sort((a, b) => a.model.localeCompare(b.model));
return models;
}
/**
* Loads the AI model pricing catalog into memory and schedules periodic
* background refreshes. Call once at server startup.
*/
export async function initAiModelCatalog(): Promise<void> {
await aiModelCatalog.init();
}
+101
View File
@@ -0,0 +1,101 @@
const modelKeyRegexCache = new Map<string, RegExp>();
export function isModelKeyPattern(key: string): boolean {
return key.includes("*") || key.includes("?");
}
function getModelKeyRegex(pattern: string): RegExp {
let regex = modelKeyRegexCache.get(pattern);
if (!regex) {
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
regex = new RegExp(
`^${escaped.replace(/\*/g, ".*").replace(/\?/g, ".")}$`
);
modelKeyRegexCache.set(pattern, regex);
}
return regex;
}
export function modelKeyMatches(
pattern: string,
requestedModel: string
): boolean {
return getModelKeyRegex(pattern).test(requestedModel);
}
function wildcardCharCount(key: string): number {
let count = 0;
for (const char of key) {
if (char === "*" || char === "?") {
count += 1;
}
}
return count;
}
function literalLength(key: string): number {
return key.replace(/[*?]/g, "").length;
}
/**
* Sort comparator: more specific patterns sort before less specific ones
* (negative when `a` is more specific than `b`).
*
* 1. Exact keys beat patterns
* 2. Fewer wildcard characters win
* 3. Longer literal length wins
*/
export function compareModelKeySpecificity(a: string, b: string): number {
const aIsPattern = isModelKeyPattern(a);
const bIsPattern = isModelKeyPattern(b);
if (aIsPattern !== bIsPattern) {
return aIsPattern ? 1 : -1;
}
const wildcardDiff = wildcardCharCount(a) - wildcardCharCount(b);
if (wildcardDiff !== 0) {
return wildcardDiff;
}
return literalLength(b) - literalLength(a);
}
/**
* Provider-layer policy: empty allowlist denies all. Blocklist only applies
* after an allow match.
*/
export function isAllowedByLists(
requested: string,
allows: string[],
blocks: string[]
): boolean {
if (allows.length === 0) {
return false;
}
if (!allows.some((pattern) => modelKeyMatches(pattern, requested))) {
return false;
}
if (blocks.some((pattern) => modelKeyMatches(pattern, requested))) {
return false;
}
return true;
}
/**
* Among allow patterns that match `requested`, return the most specific one,
* or null if none match.
*/
export function mostSpecificMatchingAllow(
requested: string,
allows: string[]
): string | null {
const matching = allows.filter((pattern) =>
modelKeyMatches(pattern, requested)
);
if (matching.length === 0) {
return null;
}
matching.sort(compareModelKeySpecificity);
return matching[0];
}
+148
View File
@@ -0,0 +1,148 @@
import type { AiProviderType } from "@server/lib/aiProviderDefaults";
import type { AiUsage } from "@server/lib/aiUsageExtraction";
import {
aiModelCatalog,
getCatalogProviderForType,
type AiModelCatalogEntry,
type CatalogProvider
} from "@server/lib/aiModelCatalog";
export type AiModelPricing = {
inputCostPerToken: number | null;
outputCostPerToken: number | null;
cacheReadInputTokenCost: number | null;
outputCostPerReasoningToken: number | null;
// True when the match came from a different catalog provider than the
// one mapped to this provider's type (e.g. an openRouter/custom model
// id that only matched a global search across every provider). Costs
// found this way are a best-effort approximation, not a guarantee the
// upstream provider bills at the same rate.
approximate: boolean;
};
function stripVendorPrefix(modelId: string): string | null {
const idx = modelId.indexOf("/");
if (idx === -1 || idx === modelId.length - 1) {
return null;
}
return modelId.slice(idx + 1);
}
function toPricing(
entry: AiModelCatalogEntry,
approximate: boolean
): AiModelPricing {
return {
inputCostPerToken: entry.pricing.in,
outputCostPerToken: entry.pricing.out,
cacheReadInputTokenCost: entry.pricing.cache,
outputCostPerReasoningToken: entry.pricing.reasoning,
approximate
};
}
function findEntry(
modelId: string,
provider: CatalogProvider | null
): AiModelCatalogEntry | null {
const candidates = [modelId, stripVendorPrefix(modelId)].filter(
(v): v is string => v != null
);
for (const key of candidates) {
if (provider) {
const match = aiModelCatalog.get(provider, key);
if (match) {
return match;
}
continue;
}
const match = aiModelCatalog.listByKey(key)[0];
if (match) {
return match;
}
}
return null;
}
/**
* Looks up per-token pricing for a model, scoped first to the catalog
* provider that corresponds to our provider type, then falling back to a
* global search across every provider (marked `approximate`) for provider
* types that proxy arbitrary underlying models.
*/
export function getModelPricing(
providerType: AiProviderType,
modelId: string | undefined
): AiModelPricing | null {
if (!modelId) {
return null;
}
const catalogProvider = getCatalogProviderForType(providerType);
if (catalogProvider) {
const scoped = findEntry(modelId, catalogProvider);
if (scoped) {
return toPricing(scoped, false);
}
}
const fallback = findEntry(modelId, null);
if (fallback) {
return toPricing(fallback, true);
}
return null;
}
export type AiCostBreakdown = {
promptCost: number;
cacheReadCost: number;
cacheWriteCost: number;
completionCost: number;
reasoningCost: number;
totalCost: number;
};
/**
* Computes a $ cost breakdown for a usage record given a model's pricing.
* Cache writes and reasoning tokens fall back to the normal input/output
* rate respectively when the catalog has no dedicated rate for them (the
* catalog has no cache-write field at all, and only some models report a
* distinct reasoning rate).
*/
export function calculateAiCost(
pricing: AiModelPricing | null,
usage: AiUsage
): AiCostBreakdown | null {
if (!pricing) {
return null;
}
const inputRate = pricing.inputCostPerToken ?? 0;
const outputRate = pricing.outputCostPerToken ?? 0;
const cacheReadRate = pricing.cacheReadInputTokenCost ?? inputRate;
const reasoningRate = pricing.outputCostPerReasoningToken ?? outputRate;
const promptCost = usage.promptTokens * inputRate;
const cacheReadCost = usage.cacheReadTokens * cacheReadRate;
const cacheWriteCost = usage.cacheWriteTokens * inputRate;
const completionCost = usage.completionTokens * outputRate;
const reasoningCost = usage.reasoningTokens * reasoningRate;
return {
promptCost,
cacheReadCost,
cacheWriteCost,
completionCost,
reasoningCost,
totalCost:
promptCost +
cacheReadCost +
cacheWriteCost +
completionCost +
reasoningCost
};
}
+186
View File
@@ -0,0 +1,186 @@
import { decrypt, encrypt } from "@server/lib/crypto";
import {
parseCapabilities,
type AiCapability
} from "@server/lib/aiCapabilities";
import { stripVirtualApiKeyAuthHeaders } from "@app/lib/virtualApiKeyFormat";
import {
AI_PROVIDER_AUTH_TYPES,
AI_PROVIDER_DEFAULTS,
authTypeRequiresApiKey,
defaultsForProviderType,
providerRequiresUpstreamUrl,
type AiBudgetUnit,
type AiProviderAuthType,
type AiProviderRoutingMode,
type AiProviderType
} from "@app/lib/aiProviderDefaults";
export {
AI_PROVIDER_AUTH_TYPES,
AI_PROVIDER_DEFAULTS,
authTypeRequiresApiKey,
defaultsForProviderType,
providerRequiresUpstreamUrl,
type AiBudgetUnit,
type AiProviderAuthType,
type AiProviderRoutingMode,
type AiProviderType
};
const CONFLICTING_AUTH_HEADERS = [
"authorization",
"x-api-key",
"x-goog-api-key",
"cf-aig-authorization"
] as const;
export function resolveAiProviderCreateFields(input: {
type: AiProviderType;
upstreamUrl?: string | null;
authType?: AiProviderAuthType | null;
routingMode?: AiProviderRoutingMode | null;
}): {
upstreamUrl: string | null;
authType: AiProviderAuthType;
routingMode: AiProviderRoutingMode;
} {
const routingMode =
input.type === "custom" ? (input.routingMode ?? "url") : "url";
if (routingMode === "target") {
return {
upstreamUrl: null,
authType: input.authType ?? "bearer",
routingMode
};
}
if (input.type === "custom") {
return {
upstreamUrl: input.upstreamUrl ?? null,
authType: input.authType ?? "bearer",
routingMode
};
}
const defaults = AI_PROVIDER_DEFAULTS[input.type];
return {
upstreamUrl: input.upstreamUrl ?? defaults.upstreamUrl,
authType: input.authType ?? defaults.authType,
routingMode
};
}
export type AiProviderHeader = { name: string; value: string };
export function serializeAiProviderHeaders(
headers: AiProviderHeader[] | null | undefined,
secret: string
): string | null {
if (!headers || headers.length === 0) {
return null;
}
return encrypt(JSON.stringify(headers), secret);
}
export function parseAiProviderHeaders(
raw: string | null | undefined,
secret: string
): AiProviderHeader[] {
if (!raw) {
return [];
}
try {
const decrypted = decrypt(raw, secret);
const parsed = JSON.parse(decrypted);
if (!Array.isArray(parsed)) {
return [];
}
return parsed.filter(
(h): h is AiProviderHeader =>
h != null &&
typeof h === "object" &&
typeof h.name === "string" &&
typeof h.value === "string"
);
} catch {
return [];
}
}
export function applyAiProviderCustomHeaders(
headers: Record<string, string>,
raw: string | null | undefined,
secret: string
): void {
for (const { name, value } of parseAiProviderHeaders(raw, secret)) {
headers[name] = value;
}
}
/**
* Apply provider auth to upstream headers.
* - Always strips Pangolin virtual API key credentials from client auth headers.
* - Injected modes: strip conflicting client auth headers, then set the provider key.
* - none: strip conflicting client auth headers, send no auth.
* - passthrough: leave remaining client auth headers as-is (after VAK strip).
*/
export function applyAiProviderAuthHeaders(
headers: Record<string, string>,
authType: AiProviderAuthType,
apiKey: string | null
): void {
stripVirtualApiKeyAuthHeaders(headers);
if (authType === "passthrough") {
return;
}
for (const name of CONFLICTING_AUTH_HEADERS) {
for (const key of Object.keys(headers)) {
if (key.toLowerCase() === name) {
delete headers[key];
}
}
}
if (authType === "none") {
return;
}
if (!apiKey) {
throw new Error(`API key required for authType ${authType}`);
}
switch (authType) {
case "bearer":
headers["Authorization"] = `Bearer ${apiKey}`;
break;
case "x-api-key":
headers["x-api-key"] = apiKey;
break;
case "x-goog-api-key":
headers["x-goog-api-key"] = apiKey;
break;
case "hec":
headers["Authorization"] = `Splunk ${apiKey}`;
break;
case "cf-aig-authorization":
headers["cf-aig-authorization"] = `Bearer ${apiKey}`;
break;
}
}
export function resolveCapabilitiesForCreate(input: {
type: AiProviderType;
capabilities?: AiCapability[] | null;
}): AiCapability[] {
if (input.capabilities != null) {
return parseCapabilities(input.capabilities);
}
if (input.type === "custom") {
return [];
}
return [...AI_PROVIDER_DEFAULTS[input.type].capabilities];
}
+97
View File
@@ -0,0 +1,97 @@
import {
aiModelCatalog,
getCatalogProviderForType,
type CatalogProvider
} from "@server/lib/aiModelCatalog";
import type { AiProviderType } from "@server/lib/aiProviderDefaults";
function stripVendorPrefix(modelId: string): string | null {
const idx = modelId.indexOf("/");
if (idx === -1 || idx === modelId.length - 1) {
return null;
}
return modelId.slice(idx + 1);
}
function modelKeysToTry(modelId: string): string[] {
const keys = [modelId];
const stripped = stripVendorPrefix(modelId);
if (stripped) {
keys.push(stripped);
}
return keys;
}
function catalogOwnsModel(
catalogProvider: CatalogProvider,
modelId: string
): boolean {
for (const key of modelKeysToTry(modelId)) {
if (aiModelCatalog.get(catalogProvider, key)) {
return true;
}
}
return false;
}
function modelKnownInAnyCatalog(modelId: string): boolean {
for (const key of modelKeysToTry(modelId)) {
if (aiModelCatalog.listByKey(key).length > 0) {
return true;
}
}
return false;
}
/**
* How strongly a provider "owns" a requested model id via the known catalog.
*
* 2 - Typed provider whose catalog contains the model
* 1 - Aggregator/custom that can proxy a catalog-known model
* 0 - No ownership signal (typed miss, or unknown model on aggregator/custom)
*/
export function catalogOwnershipScore(
type: AiProviderType,
modelId: string
): number {
const catalogProvider = getCatalogProviderForType(type);
if (catalogProvider != null) {
return catalogOwnsModel(catalogProvider, modelId) ? 2 : 0;
}
return modelKnownInAnyCatalog(modelId) ? 1 : 0;
}
/**
* Prefer native vendor providers over aggregators over custom when catalog
* ownership is tied.
*
* 2 - Native typed provider (openai, anthropic, gemini, ...)
* 1 - Aggregator gateway (openRouter, vercelAiGateway)
* 0 - Custom
*/
export function providerClassRank(type: AiProviderType): number {
if (type === "custom") {
return 0;
}
if (type === "openRouter" || type === "vercelAiGateway") {
return 1;
}
return 2;
}
export function keepBestScored<T>(
items: T[],
scoreFn: (item: T) => number
): T[] {
if (items.length <= 1) {
return items;
}
let best = Number.NEGATIVE_INFINITY;
for (const item of items) {
const score = scoreFn(item);
if (score > best) {
best = score;
}
}
return items.filter((item) => scoreFn(item) === best);
}
+482
View File
@@ -0,0 +1,482 @@
import { encode } from "gpt-tokenizer";
import type { AiCapability } from "@server/lib/aiCapabilities";
import logger from "@server/logger";
export type AiUsage = {
// Input tokens billed at the normal input rate (i.e. NOT already
// covered by cacheReadTokens/cacheWriteTokens below).
promptTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
// Output tokens billed at the normal output rate (i.e. NOT already
// covered by reasoningTokens below).
completionTokens: number;
reasoningTokens: number;
// True when these numbers are our own best-guess estimate (the upstream
// response didn't report usage), rather than provider-reported figures.
estimated: boolean;
};
function emptyUsage(): AiUsage {
return {
promptTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
completionTokens: 0,
reasoningTokens: 0,
estimated: false
};
}
/**
* Scans raw (possibly binary-framed, e.g. Bedrock's vnd.amazon.eventstream)
* text for `"fieldName":123` occurrences and returns the last value seen for
* each field. Used as a best-effort fallback for response shapes we can't
* fully parse as JSON/SSE (streaming Bedrock, raw predict passthroughs).
*/
function scanNumericFields(
text: string,
fields: string[]
): Record<string, number> {
const out: Record<string, number> = {};
for (const field of fields) {
const re = new RegExp(`"${field}"\\s*:\\s*(\\d+)`, "g");
let match: RegExpExecArray | null;
while ((match = re.exec(text)) !== null) {
out[field] = Number(match[1]);
}
}
return out;
}
// Exported for reuse by server/lib/aiMessageNormalization.ts, which needs
// the same SSE-frame/JSON-parsing groundwork to extract message content
// instead of usage numbers.
export function sseDataFrames(text: string): string[] {
const frames: string[] = [];
for (const rawFrame of text.split(/\r?\n\r?\n/)) {
for (const line of rawFrame.split(/\r?\n/)) {
if (!line.startsWith("data:")) continue;
const data = line.slice("data:".length).trim();
if (data && data !== "[DONE]") {
frames.push(data);
}
}
}
return frames;
}
export function tryParseJson(text: string): any | null {
try {
return JSON.parse(text);
} catch {
return null;
}
}
function extractOpenAiChat(text: string, isStream: boolean): AiUsage | null {
let usage: any = null;
if (isStream) {
for (const frame of sseDataFrames(text)) {
const parsed = tryParseJson(frame);
if (parsed?.usage) {
usage = parsed.usage;
}
}
} else {
usage = tryParseJson(text)?.usage ?? null;
}
if (!usage) {
return null;
}
const cacheReadTokens = usage.prompt_tokens_details?.cached_tokens ?? 0;
const reasoningTokens =
usage.completion_tokens_details?.reasoning_tokens ?? 0;
return {
promptTokens: Math.max(0, (usage.prompt_tokens ?? 0) - cacheReadTokens),
cacheReadTokens,
cacheWriteTokens: 0,
completionTokens: Math.max(
0,
(usage.completion_tokens ?? 0) - reasoningTokens
),
reasoningTokens,
estimated: false
};
}
function extractOpenAiResponses(
text: string,
isStream: boolean
): AiUsage | null {
let usage: any = null;
if (isStream) {
for (const frame of sseDataFrames(text)) {
const parsed = tryParseJson(frame);
if (
parsed?.type === "response.completed" &&
parsed?.response?.usage
) {
usage = parsed.response.usage;
} else if (parsed?.usage) {
usage = parsed.usage;
}
}
} else {
const parsed = tryParseJson(text);
usage = parsed?.usage ?? parsed?.response?.usage ?? null;
}
if (!usage) {
return null;
}
const cacheReadTokens = usage.input_tokens_details?.cached_tokens ?? 0;
const reasoningTokens = usage.output_tokens_details?.reasoning_tokens ?? 0;
return {
promptTokens: Math.max(0, (usage.input_tokens ?? 0) - cacheReadTokens),
cacheReadTokens,
cacheWriteTokens: 0,
completionTokens: Math.max(
0,
(usage.output_tokens ?? 0) - reasoningTokens
),
reasoningTokens,
estimated: false
};
}
function extractAnthropicMessages(
text: string,
isStream: boolean
): AiUsage | null {
let inputTokens = 0;
let cacheReadTokens = 0;
let cacheWriteTokens = 0;
let outputTokens = 0;
let found = false;
const applyUsage = (usage: any) => {
if (!usage) return;
found = true;
if (typeof usage.input_tokens === "number") {
inputTokens = usage.input_tokens;
}
if (typeof usage.cache_read_input_tokens === "number") {
cacheReadTokens = usage.cache_read_input_tokens;
}
if (typeof usage.cache_creation_input_tokens === "number") {
cacheWriteTokens = usage.cache_creation_input_tokens;
}
if (typeof usage.output_tokens === "number") {
outputTokens = usage.output_tokens;
}
};
if (isStream) {
for (const frame of sseDataFrames(text)) {
const parsed = tryParseJson(frame);
if (!parsed) continue;
applyUsage(parsed.message?.usage);
applyUsage(parsed.usage);
}
} else {
applyUsage(tryParseJson(text)?.usage);
}
if (!found) {
return null;
}
return {
promptTokens: inputTokens,
cacheReadTokens,
cacheWriteTokens,
completionTokens: outputTokens,
// Anthropic bills extended-thinking output at the normal output
// rate, so there's no separate reasoning bucket to report.
reasoningTokens: 0,
estimated: false
};
}
function extractGoogleGenerateContent(
text: string,
_isStream: boolean
): AiUsage | null {
// Both the plain-JSON-array stream format and the SSE (?alt=sse) format
// repeat a cumulative `usageMetadata` object per chunk; the regex scan
// below naturally picks up the last (most complete) one either way.
const fields = scanNumericFields(text, [
"promptTokenCount",
"candidatesTokenCount",
"cachedContentTokenCount",
"thoughtsTokenCount"
]);
if (fields.promptTokenCount === undefined) {
return null;
}
const cacheReadTokens = fields.cachedContentTokenCount ?? 0;
const reasoningTokens = fields.thoughtsTokenCount ?? 0;
return {
promptTokens: Math.max(0, fields.promptTokenCount - cacheReadTokens),
cacheReadTokens,
cacheWriteTokens: 0,
completionTokens: fields.candidatesTokenCount ?? 0,
reasoningTokens,
estimated: false
};
}
function extractBedrockConverse(
text: string,
_isStream: boolean
): AiUsage | null {
// Non-streaming responses are plain JSON; converse-stream frames the
// final `metadata` event's usage object inside binary event-stream
// framing, but the JSON text survives intact inside that binary
// envelope, so the same field scan works for both.
const parsed = tryParseJson(text);
const usage = parsed?.usage;
if (usage) {
const cacheReadTokens = usage.cacheReadInputTokens ?? 0;
return {
promptTokens: Math.max(
0,
(usage.inputTokens ?? 0) - cacheReadTokens
),
cacheReadTokens,
cacheWriteTokens: usage.cacheWriteInputTokens ?? 0,
completionTokens: usage.outputTokens ?? 0,
reasoningTokens: 0,
estimated: false
};
}
const fields = scanNumericFields(text, [
"inputTokens",
"outputTokens",
"cacheReadInputTokens",
"cacheWriteInputTokens"
]);
if (fields.inputTokens === undefined) {
return null;
}
const cacheReadTokens = fields.cacheReadInputTokens ?? 0;
return {
promptTokens: Math.max(0, fields.inputTokens - cacheReadTokens),
cacheReadTokens,
cacheWriteTokens: fields.cacheWriteInputTokens ?? 0,
completionTokens: fields.outputTokens ?? 0,
reasoningTokens: 0,
estimated: false
};
}
function extractBedrockModelInvoke(
text: string,
_isStream: boolean,
headers: Headers
): AiUsage | null {
// Non-streaming invoke reports counts via response headers regardless
// of the underlying model's payload format.
const headerInput = headers.get("x-amzn-bedrock-input-token-count");
const headerOutput = headers.get("x-amzn-bedrock-output-token-count");
if (headerInput !== null || headerOutput !== null) {
return {
promptTokens: Number(headerInput ?? 0),
cacheReadTokens: 0,
cacheWriteTokens: 0,
completionTokens: Number(headerOutput ?? 0),
reasoningTokens: 0,
estimated: false
};
}
// invoke-with-response-stream has no equivalent headers; the model's
// own usage shape (frequently Anthropic-style on Bedrock) is embedded
// inside binary event-stream framing, so fall back to a couple of
// known field-name shapes via regex.
const anthropicStyle = extractAnthropicMessages(text, true);
if (anthropicStyle) {
return anthropicStyle;
}
const fields = scanNumericFields(text, [
"inputTokenCount",
"outputTokenCount"
]);
if (fields.inputTokenCount === undefined) {
return null;
}
return {
promptTokens: fields.inputTokenCount,
cacheReadTokens: 0,
cacheWriteTokens: 0,
completionTokens: fields.outputTokenCount ?? 0,
reasoningTokens: 0,
estimated: false
};
}
const EXTRACTORS: Record<
AiCapability,
(text: string, isStream: boolean, headers: Headers) => AiUsage | null
> = {
openai_chat: extractOpenAiChat,
openai_responses: extractOpenAiResponses,
anthropic_messages: extractAnthropicMessages,
gemini_generate_content: extractGoogleGenerateContent,
google_generate_content: extractGoogleGenerateContent,
// rawPredict is a passthrough to whatever the underlying publisher
// model speaks (often Anthropic-shaped on Vertex); try that, then give
// up to the token-count estimate.
google_raw_predict: (text, isStream) =>
extractAnthropicMessages(text, isStream),
bedrock_model_invoke: extractBedrockModelInvoke,
bedrock_converse: extractBedrockConverse
};
/**
* Attempts to pull provider-reported token usage out of an upstream AI
* gateway response. Returns null if the response didn't contain (or we
* couldn't find) usage data, in which case callers should fall back to
* `estimateUsage`.
*/
export function extractUsage(
capability: AiCapability,
responseText: string,
isStream: boolean,
headers: Headers
): AiUsage | null {
try {
return EXTRACTORS[capability](responseText, isStream, headers);
} catch (error) {
logger.debug("Failed to extract AI usage from response", {
capability,
error
});
return null;
}
}
/**
* Best-guess token estimate for when the provider doesn't report usage.
* Uses OpenAI's BPE tokenizer as a stand-in for whatever tokenizer the
* actual model uses - close enough for an approximate cost figure, not
* exact for non-OpenAI models.
*/
export function estimateUsage(
promptText: string,
completionText: string
): AiUsage {
const usage = emptyUsage();
usage.estimated = true;
try {
usage.promptTokens = promptText ? encode(promptText).length : 0;
} catch (error) {
logger.debug("Failed to estimate prompt tokens", { error });
}
try {
usage.completionTokens = completionText
? encode(completionText).length
: 0;
} catch (error) {
logger.debug("Failed to estimate completion tokens", { error });
}
return usage;
}
/**
* OpenAI's Chat Completions API only includes a `usage` field in a
* streaming response when the request opts in via `stream_options:
* {include_usage: true}` - unlike the Responses API, Anthropic, Gemini and
* Bedrock, which report usage in a streaming response by default. Returns
* whether we need to inject that option ourselves to be able to track cost.
*/
export function needsStreamUsageInjection(
capability: AiCapability,
body: any
): boolean {
return (
capability === "openai_chat" &&
body?.stream === true &&
body?.stream_options?.include_usage !== true
);
}
/**
* Returns a shallow-cloned body with `stream_options.include_usage`
* injected, for capabilities/requests where `needsStreamUsageInjection`
* is true. Leaves the original body untouched.
*/
export function withStreamUsageOption(body: any): any {
return {
...body,
stream_options: { ...body.stream_options, include_usage: true }
};
}
/**
* When we injected stream_options.include_usage ourselves (the caller
* didn't ask for it), OpenAI appends an extra terminal SSE frame with an
* empty `choices: []` array carrying only the usage data. Callers that
* don't expect that shape (most minimal SSE parsers assume a non-empty
* choices array) shouldn't see it, so it's stripped back out of the bytes
* forwarded to the client.
*/
export function stripInjectedUsageFrame(sseText: string): string {
const parts = sseText.split(/(\r?\n\r?\n)/);
let out = "";
for (let i = 0; i < parts.length; i += 2) {
const frame = parts[i];
const separator = parts[i + 1] ?? "";
const dataLine = frame
.split(/\r?\n/)
.find((line) => line.startsWith("data:"));
if (dataLine) {
const data = dataLine.slice("data:".length).trim();
const parsed = data !== "[DONE]" ? tryParseJson(data) : null;
if (
parsed &&
Array.isArray(parsed.choices) &&
parsed.choices.length === 0 &&
parsed.usage
) {
continue;
}
}
out += frame + separator;
}
return out;
}
/**
* Best-effort extraction of the model the upstream provider actually
* served, which some gateways/routers echo back and which may differ from
* the model the caller requested (e.g. an alias resolving to a dated
* snapshot). Falls back to the caller's requested model when absent.
*/
export function extractResponseModel(responseText: string): string | null {
const match = responseText.match(/"model"\s*:\s*"([^"]+)"/);
return match ? match[1] : null;
}
export function isUsageEmpty(usage: AiUsage): boolean {
return (
usage.promptTokens === 0 &&
usage.cacheReadTokens === 0 &&
usage.cacheWriteTokens === 0 &&
usage.completionTokens === 0 &&
usage.reasoningTokens === 0
);
}
@@ -202,6 +202,10 @@ async function handleResource(
return;
}
if (!target.resourceId) {
return;
}
const [resource] = await trx
.select()
.from(resources)
@@ -227,9 +231,7 @@ async function handleResource(
let health = "healthy";
const allUnknown = monitoredTargets.length === 0;
const allHealthy = monitoredTargets.every(
(t) => t.hcHealth === "healthy"
);
const allHealthy = monitoredTargets.every((t) => t.hcHealth === "healthy");
const allUnhealthy = monitoredTargets.every(
(t) => t.hcHealth === "unhealthy"
);
+36 -26
View File
@@ -1,28 +1,39 @@
export enum FeatureId {
export enum LimitId {
USERS = "users",
SITES = "sites",
EGRESS_DATA_MB = "egressDataMb",
DOMAINS = "domains",
REMOTE_EXIT_NODES = "remoteExitNodes",
ORGINIZATIONS = "organizations",
ORGANIZATIONS = "organizations",
PUBLIC_RESOURCES = "publicResources",
PRIVATE_RESOURCES = "privateResources",
MACHINE_CLIENTS = "machineClients",
TIER1 = "tier1"
}
export async function getFeatureDisplayName(featureId: FeatureId): Promise<string> {
export async function getFeatureDisplayName(
featureId: LimitId
): Promise<string> {
switch (featureId) {
case FeatureId.USERS:
case LimitId.USERS:
return "Users";
case FeatureId.SITES:
case LimitId.SITES:
return "Sites";
case FeatureId.EGRESS_DATA_MB:
case LimitId.EGRESS_DATA_MB:
return "Egress Data (MB)";
case FeatureId.DOMAINS:
case LimitId.DOMAINS:
return "Domains";
case FeatureId.REMOTE_EXIT_NODES:
case LimitId.REMOTE_EXIT_NODES:
return "Remote Exit Nodes";
case FeatureId.ORGINIZATIONS:
case LimitId.ORGANIZATIONS:
return "Organizations";
case FeatureId.TIER1:
case LimitId.PUBLIC_RESOURCES:
return "Public Resources";
case LimitId.PRIVATE_RESOURCES:
return "Private Resources";
case LimitId.MACHINE_CLIENTS:
return "Machine Clients";
case LimitId.TIER1:
return "Home Lab";
default:
return featureId;
@@ -30,15 +41,16 @@ export async function getFeatureDisplayName(featureId: FeatureId): Promise<strin
}
// this is from the old system
export const FeatureMeterIds: Partial<Record<FeatureId, string>> = { // right now we are not charging for any data
export const FeatureMeterIds: Partial<Record<LimitId, string>> = {
// right now we are not charging for any data
// [FeatureId.EGRESS_DATA_MB]: "mtr_61Srreh9eWrExDSCe41D3Ee2Ir7Wm5YW"
};
export const FeatureMeterIdsSandbox: Partial<Record<FeatureId, string>> = {
export const FeatureMeterIdsSandbox: Partial<Record<LimitId, string>> = {
// [FeatureId.EGRESS_DATA_MB]: "mtr_test_61Snh2a2m6qome5Kv41DCpkOb237B3dQ"
};
export function getFeatureMeterId(featureId: FeatureId): string | undefined {
export function getFeatureMeterId(featureId: LimitId): string | undefined {
if (
process.env.ENVIRONMENT == "prod" &&
process.env.SANDBOX_MODE !== "true"
@@ -49,22 +61,20 @@ export function getFeatureMeterId(featureId: FeatureId): string | undefined {
}
}
export function getFeatureIdByMetricId(
metricId: string
): FeatureId | undefined {
return (Object.entries(FeatureMeterIds) as [FeatureId, string][]).find(
export function getFeatureIdByMetricId(metricId: string): LimitId | undefined {
return (Object.entries(FeatureMeterIds) as [LimitId, string][]).find(
([_, v]) => v === metricId
)?.[0];
}
export type FeaturePriceSet = Partial<Record<FeatureId, string>>;
export type FeaturePriceSet = Partial<Record<LimitId, string>>;
export const tier1FeaturePriceSet: FeaturePriceSet = {
[FeatureId.TIER1]: "price_1SzVE3D3Ee2Ir7Wm6wT5Dl3G"
[LimitId.TIER1]: "price_1SzVE3D3Ee2Ir7Wm6wT5Dl3G"
};
export const tier1FeaturePriceSetSandbox: FeaturePriceSet = {
[FeatureId.TIER1]: "price_1SxgpPDCpkOb237Bfo4rIsoT"
[LimitId.TIER1]: "price_1SxgpPDCpkOb237Bfo4rIsoT"
};
export function getTier1FeaturePriceSet(): FeaturePriceSet {
@@ -79,11 +89,11 @@ export function getTier1FeaturePriceSet(): FeaturePriceSet {
}
export const tier2FeaturePriceSet: FeaturePriceSet = {
[FeatureId.USERS]: "price_1SzVCcD3Ee2Ir7Wmn6U3KvPN"
[LimitId.USERS]: "price_1SzVCcD3Ee2Ir7Wmn6U3KvPN"
};
export const tier2FeaturePriceSetSandbox: FeaturePriceSet = {
[FeatureId.USERS]: "price_1SxaEHDCpkOb237BD9lBkPiR"
[LimitId.USERS]: "price_1SxaEHDCpkOb237BD9lBkPiR"
};
export function getTier2FeaturePriceSet(): FeaturePriceSet {
@@ -98,11 +108,11 @@ export function getTier2FeaturePriceSet(): FeaturePriceSet {
}
export const tier3FeaturePriceSet: FeaturePriceSet = {
[FeatureId.USERS]: "price_1SzVDKD3Ee2Ir7WmPtOKNusv"
[LimitId.USERS]: "price_1SzVDKD3Ee2Ir7WmPtOKNusv"
};
export const tier3FeaturePriceSetSandbox: FeaturePriceSet = {
[FeatureId.USERS]: "price_1SxaEODCpkOb237BiXdCBSfs"
[LimitId.USERS]: "price_1SxaEODCpkOb237BiXdCBSfs"
};
export function getTier3FeaturePriceSet(): FeaturePriceSet {
@@ -116,7 +126,7 @@ export function getTier3FeaturePriceSet(): FeaturePriceSet {
}
}
export function getFeatureIdByPriceId(priceId: string): FeatureId | undefined {
export function getFeatureIdByPriceId(priceId: string): LimitId | undefined {
// Check all feature price sets
const allPriceSets = [
getTier1FeaturePriceSet(),
@@ -125,7 +135,7 @@ export function getFeatureIdByPriceId(priceId: string): FeatureId | undefined {
];
for (const priceSet of allPriceSets) {
const entry = (Object.entries(priceSet) as [FeatureId, string][]).find(
const entry = (Object.entries(priceSet) as [LimitId, string][]).find(
([_, price]) => price === priceId
);
if (entry) {
+5 -5
View File
@@ -1,19 +1,19 @@
import Stripe from "stripe";
import { FeatureId, FeaturePriceSet } from "./features";
import { LimitId, FeaturePriceSet } from "./features";
import { usageService } from "./usageService";
export async function getLineItems(
featurePriceSet: FeaturePriceSet,
orgId: string,
orgId: string
): Promise<Stripe.Checkout.SessionCreateParams.LineItem[]> {
const users = await usageService.getUsage(orgId, FeatureId.USERS);
const users = await usageService.getUsage(orgId, LimitId.USERS);
return Object.entries(featurePriceSet).map(([featureId, priceId]) => {
let quantity: number | undefined;
if (featureId === FeatureId.USERS) {
if (featureId === LimitId.USERS) {
quantity = users?.instantaneousValue || 1;
} else if (featureId === FeatureId.TIER1) {
} else if (featureId === LimitId.TIER1) {
quantity = 1;
}
+35 -23
View File
@@ -1,70 +1,82 @@
import { FeatureId } from "./features";
import { LimitId } from "./features";
export type LimitSet = Partial<{
[key in FeatureId]: {
[key in LimitId]: {
value: number | null; // null indicates no limit
description?: string;
};
}>;
export const freeLimitSet: LimitSet = {
[FeatureId.SITES]: { value: 5, description: "Basic limit" },
[FeatureId.USERS]: { value: 5, description: "Basic limit" },
[FeatureId.DOMAINS]: { value: 5, description: "Basic limit" },
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Basic limit" },
[FeatureId.ORGINIZATIONS]: { value: 1, description: "Basic limit" },
[LimitId.SITES]: { value: 5, description: "Basic limit" },
[LimitId.USERS]: { value: 5, description: "Basic limit" },
[LimitId.DOMAINS]: { value: 5, description: "Basic limit" },
[LimitId.REMOTE_EXIT_NODES]: { value: 1, description: "Basic limit" },
[LimitId.ORGANIZATIONS]: { value: 1, description: "Basic limit" },
[LimitId.PUBLIC_RESOURCES]: { value: 15, description: "Basic limit" },
[LimitId.PRIVATE_RESOURCES]: { value: 15, description: "Basic limit" },
[LimitId.MACHINE_CLIENTS]: { value: 5, description: "Basic limit" }
};
export const tier1LimitSet: LimitSet = {
[FeatureId.USERS]: { value: 7, description: "Home limit" },
[FeatureId.SITES]: { value: 10, description: "Home limit" },
[FeatureId.DOMAINS]: { value: 10, description: "Home limit" },
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Home limit" },
[FeatureId.ORGINIZATIONS]: { value: 1, description: "Home limit" },
[LimitId.USERS]: { value: 7, description: "Home limit" },
[LimitId.SITES]: { value: 10, description: "Home limit" },
[LimitId.DOMAINS]: { value: 10, description: "Home limit" },
[LimitId.REMOTE_EXIT_NODES]: { value: 1, description: "Home limit" },
[LimitId.ORGANIZATIONS]: { value: 1, description: "Home limit" },
[LimitId.PUBLIC_RESOURCES]: { value: 30, description: "Home limit" },
[LimitId.PRIVATE_RESOURCES]: { value: 30, description: "Home limit" },
[LimitId.MACHINE_CLIENTS]: { value: 10, description: "Home limit" }
};
export const tier2LimitSet: LimitSet = {
[FeatureId.USERS]: {
[LimitId.USERS]: {
value: 50,
description: "Team limit"
},
[FeatureId.SITES]: {
[LimitId.SITES]: {
value: 50,
description: "Team limit"
},
[FeatureId.DOMAINS]: {
[LimitId.DOMAINS]: {
value: 50,
description: "Team limit"
},
[FeatureId.REMOTE_EXIT_NODES]: {
[LimitId.REMOTE_EXIT_NODES]: {
value: 3,
description: "Team limit"
},
[FeatureId.ORGINIZATIONS]: {
[LimitId.ORGANIZATIONS]: {
value: 1,
description: "Team limit"
}
},
[LimitId.PUBLIC_RESOURCES]: { value: 150, description: "Team limit" },
[LimitId.PRIVATE_RESOURCES]: { value: 150, description: "Team limit" },
[LimitId.MACHINE_CLIENTS]: { value: 25, description: "Team limit" }
};
export const tier3LimitSet: LimitSet = {
[FeatureId.USERS]: {
[LimitId.USERS]: {
value: 250,
description: "Business limit"
},
[FeatureId.SITES]: {
[LimitId.SITES]: {
value: 250,
description: "Business limit"
},
[FeatureId.DOMAINS]: {
[LimitId.DOMAINS]: {
value: 100,
description: "Business limit"
},
[FeatureId.REMOTE_EXIT_NODES]: {
[LimitId.REMOTE_EXIT_NODES]: {
value: 20,
description: "Business limit"
},
[FeatureId.ORGINIZATIONS]: {
[LimitId.ORGANIZATIONS]: {
value: 5,
description: "Business limit"
},
[LimitId.PUBLIC_RESOURCES]: { value: 750, description: "Business limit" },
[LimitId.PRIVATE_RESOURCES]: { value: 750, description: "Business limit" },
[LimitId.MACHINE_CLIENTS]: { value: 100, description: "Business limit" }
};
+2 -2
View File
@@ -1,7 +1,7 @@
import { db, limits } from "@server/db";
import { and, eq } from "drizzle-orm";
import { LimitSet } from "./limitSet";
import { FeatureId } from "./features";
import { LimitId } from "./features";
import logger from "@server/logger";
class LimitService {
@@ -38,7 +38,7 @@ class LimitService {
async getOrgLimit(
orgId: string,
featureId: FeatureId
featureId: LimitId
): Promise<number | null> {
const limitId = `${orgId}-${featureId}`;
const [limit] = await db
+4 -8
View File
@@ -10,7 +10,7 @@ export enum TierFeature {
ActionLogs = "actionLogs", // set the retention period to none on downgrade
ConnectionLogs = "connectionLogs",
RotateCredentials = "rotateCredentials",
MaintencePage = "maintencePage", // handle downgrade
MaintenancePage = "maintenancePage", // handle downgrade
DevicePosture = "devicePosture",
TwoFactorEnforcement = "twoFactorEnforcement", // handle downgrade by setting to optional
SessionDurationPolicies = "sessionDurationPolicies", // handle downgrade by setting to default duration
@@ -23,15 +23,12 @@ export enum TierFeature {
StandaloneHealthChecks = "standaloneHealthChecks",
AlertingRules = "alertingRules",
WildcardSubdomain = "wildcardSubdomain",
Labels = "labels",
NewtAutoUpdate = "newtAutoUpdate",
ResourcePolicies = "resourcePolicies",
AdvancedPublicResources = "advancedPublicResources",
AdvancedPrivateResources = "advancedPrivateResources"
RoleBasedSSHControls = "roleBasedSSHControls"
}
export const tierMatrix: Record<TierFeature, Tier[]> = {
[TierFeature.Labels]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.OrgOidc]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.LoginPageDomain]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.DeviceApprovals]: ["tier1", "tier3", "enterprise"],
@@ -41,7 +38,7 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
[TierFeature.ActionLogs]: ["tier2", "tier3", "enterprise"],
[TierFeature.ConnectionLogs]: ["tier2", "tier3", "enterprise"],
[TierFeature.RotateCredentials]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.MaintencePage]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.MaintenancePage]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.DevicePosture]: ["tier2", "tier3", "enterprise"],
[TierFeature.TwoFactorEnforcement]: [
"tier1",
@@ -71,6 +68,5 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.NewtAutoUpdate]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.ResourcePolicies]: ["tier3", "enterprise"],
[TierFeature.AdvancedPublicResources]: ["tier3", "enterprise"],
[TierFeature.AdvancedPrivateResources]: ["tier3", "enterprise"]
[TierFeature.RoleBasedSSHControls]: ["tier3", "enterprise"]
};
+8 -8
View File
@@ -9,7 +9,7 @@ import {
Transaction,
orgs
} from "@server/db";
import { FeatureId, getFeatureMeterId } from "./features";
import { LimitId, getFeatureMeterId } from "./features";
import logger from "@server/logger";
import { build } from "@server/build";
import { regionalCache as cache } from "#dynamic/lib/cache";
@@ -37,7 +37,7 @@ export class UsageService {
public async add(
orgId: string,
featureId: FeatureId,
featureId: LimitId,
value: number,
transaction: any = null
): Promise<Usage | null> {
@@ -114,7 +114,7 @@ export class UsageService {
private async internalAddUsage(
orgId: string, // here the orgId is the billing org already resolved by getBillingOrg in updateCount
featureId: FeatureId,
featureId: LimitId,
value: number,
trx: Transaction
): Promise<Usage> {
@@ -163,7 +163,7 @@ export class UsageService {
async updateCount(
orgId: string,
featureId: FeatureId,
featureId: LimitId,
value?: number,
customerId?: string
): Promise<void> {
@@ -227,7 +227,7 @@ export class UsageService {
private async getCustomerId(
orgId: string,
featureId: FeatureId
featureId: LimitId
): Promise<string | null> {
const orgIdToUse = await this.getBillingOrg(orgId);
@@ -269,7 +269,7 @@ export class UsageService {
public async getUsage(
orgId: string,
featureId: FeatureId,
featureId: LimitId,
trx: Transaction | typeof db = db
): Promise<Usage | null> {
if (noop()) {
@@ -381,7 +381,7 @@ export class UsageService {
public async checkLimitSet(
orgId: string,
featureId?: FeatureId,
featureId?: LimitId,
usage?: Usage,
trx: Transaction | typeof db = db
): Promise<boolean> {
@@ -429,7 +429,7 @@ export class UsageService {
} else {
currentUsage = await this.getUsage(
orgIdToUse,
limit.featureId as FeatureId,
limit.featureId as LimitId,
trx
);
}
+89
View File
@@ -0,0 +1,89 @@
import { eq } from "drizzle-orm";
import { aiBudgets, Transaction } from "@server/db";
export type BlueprintAiBudgetInput = {
amount: number;
unit: "usd" | "tokens";
period:
| "monthly"
| "yearly"
| "lifetime"
| "daily"
| "hourly"
| "weekly";
enforcement: "hard" | "soft";
enabled: boolean;
};
type SyncAiBudgetsInput = {
orgId: string;
trx: Transaction;
budgets: BlueprintAiBudgetInput[];
} & (
| { scope: "public"; resourceId: number }
| { scope: "site"; siteResourceId: number }
);
/**
* Fully declarative: makes the resource's/site resource's AI budgets match
* exactly what the blueprint declares (omitted unit/period budgets are removed).
*/
export async function syncAiBudgets(input: SyncAiBudgetsInput): Promise<void> {
const { orgId, trx, budgets } = input;
const existing = await trx
.select()
.from(aiBudgets)
.where(
input.scope === "public"
? eq(aiBudgets.resourceId, input.resourceId)
: eq(aiBudgets.siteResourceId, input.siteResourceId)
);
const existingByKey = new Map(
existing.map((b) => [`${b.unit}::${b.period}`, b])
);
const seenKeys = new Set<string>();
const now = Date.now();
for (const budget of budgets) {
const key = `${budget.unit}::${budget.period}`;
seenKeys.add(key);
const existingBudget = existingByKey.get(key);
if (existingBudget) {
await trx
.update(aiBudgets)
.set({
amount: budget.amount,
enforcement: budget.enforcement,
enabled: budget.enabled,
updatedAt: now
})
.where(eq(aiBudgets.budgetId, existingBudget.budgetId));
} else {
await trx.insert(aiBudgets).values({
orgId,
resourceId: input.scope === "public" ? input.resourceId : null,
siteResourceId:
input.scope === "site" ? input.siteResourceId : null,
amount: budget.amount,
unit: budget.unit,
period: budget.period,
enforcement: budget.enforcement,
enabled: budget.enabled,
createdAt: now,
updatedAt: now
});
}
}
for (const [key, existingBudget] of existingByKey) {
if (!seenKeys.has(key)) {
await trx
.delete(aiBudgets)
.where(eq(aiBudgets.budgetId, existingBudget.budgetId));
}
}
}
+280
View File
@@ -0,0 +1,280 @@
import { and, eq, inArray } from "drizzle-orm";
import {
aiModels,
aiProviders,
resourceAiModels,
siteResourceAiModels,
Transaction
} from "@server/db";
import {
AccessMode,
ModelListType,
clearPublicResourceAiConfig,
clearSiteResourceAiConfig,
isInferenceFieldsError,
resolveProviderAttachments,
setPublicResourceAiProviders,
setSiteResourceAiProviders
} from "@server/lib/aiInferenceResource";
export type BlueprintAiModelInput = {
model: string;
listType: ModelListType;
};
export type BlueprintAiProviderInput = {
provider: string;
accessMode: AccessMode;
enabled: boolean;
models: string[];
};
async function resolveProviderNiceIds(
orgId: string,
niceIds: string[],
trx: Transaction
): Promise<Map<string, number>> {
const unique = [...new Set(niceIds)];
if (unique.length === 0) {
return new Map();
}
const rows = await trx
.select({
providerId: aiProviders.providerId,
niceId: aiProviders.niceId
})
.from(aiProviders)
.where(
and(
eq(aiProviders.orgId, orgId),
inArray(aiProviders.niceId, unique)
)
);
const byNiceId = new Map(rows.map((r) => [r.niceId, r.providerId]));
const missing = unique.filter((id) => !byNiceId.has(id));
if (missing.length > 0) {
throw new Error(
`AI provider(s) not found in this org: ${missing.join(", ")}`
);
}
return byNiceId;
}
async function resolveModelKeys(
providers: BlueprintAiProviderInput[],
providerIdByNiceId: Map<string, number>,
trx: Transaction
): Promise<Map<string, number>> {
const providerIds = [
...new Set(
providers
.filter((p) => p.models.length > 0)
.map((p) => providerIdByNiceId.get(p.provider)!)
)
];
if (providerIds.length === 0) {
return new Map();
}
const rows = await trx
.select({
modelId: aiModels.modelId,
modelKey: aiModels.modelKey,
providerId: aiModels.providerId
})
.from(aiModels)
.where(inArray(aiModels.providerId, providerIds));
const byProviderAndKey = new Map<string, number>();
for (const row of rows) {
byProviderAndKey.set(`${row.providerId}::${row.modelKey}`, row.modelId);
}
const modelIdByEntryKey = new Map<string, number>();
const missing: string[] = [];
for (const provider of providers) {
const providerId = providerIdByNiceId.get(provider.provider)!;
for (const m of provider.models) {
const modelId = byProviderAndKey.get(`${providerId}::${m}`);
if (modelId === undefined) {
missing.push(`${provider.provider}/${m}`);
continue;
}
modelIdByEntryKey.set(`${provider.provider}::${m}`, modelId);
}
}
if (missing.length > 0) {
throw new Error(`AI model(s) not found: ${missing.join(", ")}`);
}
return modelIdByEntryKey;
}
async function validateModelEntries(input: {
orgId: string;
entries: { modelId: number }[];
selectProviderIds: number[];
trx: Transaction;
}): Promise<void> {
if (input.entries.length === 0) {
return;
}
if (input.selectProviderIds.length === 0) {
throw new Error(
"Set at least one attached AI provider to access-mode 'select' before declaring models"
);
}
const modelIds = input.entries.map((e) => e.modelId);
const catalogRows = await input.trx
.select({
modelId: aiModels.modelId,
listType: aiModels.listType,
providerId: aiModels.providerId,
enabled: aiModels.enabled
})
.from(aiModels)
.innerJoin(aiProviders, eq(aiModels.providerId, aiProviders.providerId))
.where(
and(
inArray(aiModels.modelId, modelIds),
inArray(aiModels.providerId, input.selectProviderIds),
eq(aiProviders.orgId, input.orgId)
)
);
const catalogById = new Map(catalogRows.map((row) => [row.modelId, row]));
for (const entry of input.entries) {
const catalog = catalogById.get(entry.modelId);
if (!catalog) {
throw new Error(
`Model ${entry.modelId} does not exist or does not belong to a select-mode attached provider`
);
}
if (!catalog.enabled) {
throw new Error(
`Model ${entry.modelId} is disabled on its provider`
);
}
}
}
type SyncInferenceAiConfigInput = {
orgId: string;
trx: Transaction;
mode: string;
providers: BlueprintAiProviderInput[];
} & (
| { scope: "public"; resourceId: number }
| { scope: "site"; siteResourceId: number }
);
/**
* Fully declarative: makes the resource's attached AI providers/models match
* exactly what the blueprint declares (omitted providers/models are removed).
* Non-inference resources have any leftover AI config cleared.
*/
export async function syncInferenceAiConfig(
input: SyncInferenceAiConfigInput
): Promise<void> {
const { orgId, trx, mode } = input;
if (mode !== "inference") {
if (input.scope === "public") {
await clearPublicResourceAiConfig(input.resourceId, trx);
} else {
await clearSiteResourceAiConfig(input.siteResourceId, trx);
}
return;
}
const providerIdByNiceId = await resolveProviderNiceIds(
orgId,
input.providers.map((p) => p.provider),
trx
);
const resolvedAttachments = await resolveProviderAttachments({
orgId,
attachments: input.providers.map((p) => ({
providerId: providerIdByNiceId.get(p.provider)!,
accessMode: p.accessMode,
enabled: p.enabled
})),
requireAtLeastOne: false
});
if (isInferenceFieldsError(resolvedAttachments)) {
throw new Error(resolvedAttachments.error);
}
if (input.scope === "public") {
await setPublicResourceAiProviders(
input.resourceId,
resolvedAttachments,
trx
);
} else {
await setSiteResourceAiProviders(
input.siteResourceId,
resolvedAttachments,
trx
);
}
const modelIdByEntryKey = await resolveModelKeys(
input.providers,
providerIdByNiceId,
trx
);
const modelEntries = input.providers.flatMap((p) =>
p.models.map((m) => ({
modelId: modelIdByEntryKey.get(`${p.provider}::${m}`)!
}))
);
const selectProviderIds = resolvedAttachments
.filter((a) => a.accessMode === "select")
.map((a) => a.providerId);
await validateModelEntries({
orgId,
entries: modelEntries,
selectProviderIds,
trx
});
if (input.scope === "public") {
await trx
.delete(resourceAiModels)
.where(eq(resourceAiModels.resourceId, input.resourceId));
if (modelEntries.length > 0) {
await trx.insert(resourceAiModels).values(
modelEntries.map((m) => ({
resourceId: input.resourceId,
modelId: m.modelId
}))
);
}
} else {
await trx
.delete(siteResourceAiModels)
.where(
eq(siteResourceAiModels.siteResourceId, input.siteResourceId)
);
if (modelEntries.length > 0) {
await trx.insert(siteResourceAiModels).values(
modelEntries.map((m) => ({
siteResourceId: input.siteResourceId,
modelId: m.modelId
}))
);
}
}
}
+2 -1
View File
@@ -8,7 +8,7 @@ import {
userSiteResources,
clientSiteResources
} from "@server/db";
import { Config, ConfigSchema } from "./types";
import { Config, ConfigSchema, isTargetsOnlyResource } from "./types";
import {
PublicResourcesResults,
updatePublicResources
@@ -64,6 +64,7 @@ export async function applyBlueprint({
let publicResourcesResults: PublicResourcesResults = [];
let privateResourcesResults: ClientResourcesResults = [];
await db.transaction(async (trx) => {
await updateResourcePolicies(orgId, config, trx);
@@ -116,7 +116,7 @@ export async function applyNewtDockerBlueprint(
source: "NEWT"
});
} catch (error) {
logger.error(`Failed to update database from config: ${error}`);
logger.debug(`Failed to update database from config: ${error}`);
await sendToClient(newtId, {
type: "newt/blueprint/results",
data: {
+86
View File
@@ -0,0 +1,86 @@
import {
labels,
resourceLabels,
siteResourceLabels,
Transaction
} from "@server/db";
import logger from "@server/logger";
import { and, eq, sql } from "drizzle-orm";
// Matches the "gray" swatch in the label color palette used by the UI
// (src/components/labels-selector.tsx), used as the default for labels
// auto-created from a blueprint where no color is specified.
const DEFAULT_LABEL_COLOR = "#b4b4b4";
/**
* Looks up labels by name (case-insensitive) within an org, auto-creating
* any that don't already exist. Returns the resolved, de-duplicated labelIds.
*/
export async function getOrCreateLabelIds(
orgId: string,
labelNames: string[],
trx: Transaction
): Promise<number[]> {
const labelIds = new Set<number>();
for (const name of labelNames) {
let [label] = await trx
.select({ labelId: labels.labelId })
.from(labels)
.where(
and(
eq(labels.orgId, orgId),
sql`LOWER(${labels.name}) = ${name.toLowerCase()}`
)
)
.limit(1);
if (!label) {
[label] = await trx
.insert(labels)
.values({ name, color: DEFAULT_LABEL_COLOR, orgId })
.returning({ labelId: labels.labelId });
logger.info(
`Auto-created label "${name}" in org ${orgId} from blueprint`
);
}
labelIds.add(label.labelId);
}
return Array.from(labelIds);
}
export async function syncResourceLabels(
resourceId: number,
labelIds: number[],
trx: Transaction
) {
await trx
.delete(resourceLabels)
.where(eq(resourceLabels.resourceId, resourceId));
if (labelIds.length > 0) {
await trx
.insert(resourceLabels)
.values(labelIds.map((labelId) => ({ resourceId, labelId })));
}
}
export async function syncSiteResourceLabels(
siteResourceId: number,
labelIds: number[],
trx: Transaction
) {
await trx
.delete(siteResourceLabels)
.where(eq(siteResourceLabels.siteResourceId, siteResourceId));
if (labelIds.length > 0) {
await trx
.insert(siteResourceLabels)
.values(
labelIds.map((labelId) => ({ siteResourceId, labelId }))
);
}
}
+233 -60
View File
@@ -19,17 +19,22 @@ import {
import { sites } from "@server/db";
import { eq, and, ne, inArray, or, isNotNull } from "drizzle-orm";
import { Config } from "./types";
import { getOrCreateLabelIds, syncSiteResourceLabels } from "./labels";
import logger from "@server/logger";
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
import { getNextAvailableAliasAddress } from "../ip";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "../billing/tierMatrix";
import { createCertificate } from "@server/routers/certificates/createCertificate";
import { build } from "@server/build";
import { LimitId } from "../billing";
import { usageService } from "../billing/usageService";
import { syncInferenceAiConfig } from "./aiProviders";
import { syncAiBudgets } from "./aiBudgets";
async function getDomainForSiteResource(
siteResourceId: number | undefined,
fullDomain: string,
orgId: string,
isInference: boolean,
trx: Transaction
): Promise<{ subdomain: string | null; domainId: string }> {
const [fullDomainExists] = await trx
@@ -39,6 +44,11 @@ async function getDomainForSiteResource(
and(
eq(siteResources.fullDomain, fullDomain),
eq(siteResources.orgId, orgId),
// exclude looking at the ones on exit nodes if this is an inference resource,
// and vice versa, so inference and non-inference resources can share a full-domain
isInference
? ne(siteResources.mode, "inference")
: eq(siteResources.mode, "inference"),
siteResourceId
? ne(siteResources.siteResourceId, siteResourceId)
: isNotNull(siteResources.siteResourceId)
@@ -116,30 +126,6 @@ export async function updatePrivateResources(
for (const [resourceNiceId, resourceData] of Object.entries(
config["client-resources"]
)) {
if (resourceData.mode === "http") {
const hasHttpFeature = await isLicensedOrSubscribed(
orgId,
tierMatrix.advancedPrivateResources
);
if (!hasHttpFeature) {
throw new Error(
"HTTP private resources are not included in your current plan. Please upgrade."
);
}
}
if (resourceData.mode === "ssh") {
const hasSshFeature = await isLicensedOrSubscribed(
orgId,
tierMatrix.advancedPrivateResources
);
if (!hasSshFeature) {
throw new Error(
"SSH private resources are not included in your current plan. Please upgrade."
);
}
}
const [existingResource] = await trx
.select()
.from(siteResources)
@@ -195,60 +181,109 @@ export async function updatePrivateResources(
}
}
let resourceStatusFromSite: "approved" | "pending" = "approved";
if (siteId && allSites.length === 0) {
// only add if there are not provided sites
// Use the provided siteId directly, but verify it belongs to the org
const [siteSingle] = await trx
.select({ siteId: sites.siteId })
.select({ siteId: sites.siteId, status: sites.status })
.from(sites)
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
.limit(1);
if (siteSingle) {
allSites.push(siteSingle);
}
resourceStatusFromSite = siteSingle.status ?? "approved";
}
if (allSites.length === 0) {
if (resourceData.mode !== "inference" && allSites.length === 0) {
throw new Error(
`No valid sites found for private private resource ${resourceNiceId} in org ${orgId}`
);
}
const resourceEnabled =
resourceData.enabled == undefined || resourceData.enabled == null
? true
: resourceStatusFromSite === "pending"
? false
: resourceData.enabled;
const resourceSsl =
resourceData.mode === "inference" || resourceData.mode === "http"
? resourceData.ssl == undefined || resourceData.ssl == null
? true
: resourceData.ssl
: resourceData.ssl;
if (existingResource) {
let domainInfo:
| { subdomain: string | null; domainId: string }
| undefined;
if (resourceData["full-domain"] && resourceData.mode === "http") {
if (
resourceData["full-domain"] &&
(resourceData.mode === "http" ||
resourceData.mode === "inference")
) {
domainInfo = await getDomainForSiteResource(
existingResource.siteResourceId,
resourceData["full-domain"],
orgId,
resourceData.mode === "inference",
trx
);
}
if (resourceData.alias) {
const [aliasConflict] = await trx
.select({
siteResourceId: siteResources.siteResourceId
})
.from(siteResources)
.where(
and(
eq(siteResources.orgId, orgId),
eq(siteResources.alias, resourceData.alias),
ne(
siteResources.siteResourceId,
existingResource.siteResourceId
)
)
)
.limit(1);
if (aliasConflict) {
throw new Error(
`Alias ${resourceData.alias} already in use by another site resource in org ${orgId}`
);
}
}
const isInference = resourceData.mode === "inference";
// Update existing resource
const [updatedResource] = await trx
.update(siteResources)
.set({
name: resourceData.name || resourceNiceId,
mode: resourceData.mode,
ssl: resourceData.ssl,
ssl: resourceSsl,
scheme: resourceData.scheme,
destination: resourceData.destination,
destinationPort: resourceData["destination-port"],
enabled: true, // hardcoded for now
// enabled: resourceData.enabled ?? true,
enabled: resourceEnabled,
alias: resourceData.alias || null,
disableIcmp:
resourceData["disable-icmp"] ||
(resourceData.mode == "http" ? true : false), // default to true for http resources, otherwise false
(resourceData.mode == "http" || isInference
? true
: false), // default to true for http/inference resources, otherwise false
tcpPortRangeString:
resourceData.mode == "http"
resourceData.mode == "http" || isInference
? "443,80"
: resourceData["tcp-ports"],
udpPortRangeString:
resourceData.mode == "http"
resourceData.mode == "http" || isInference
? ""
: resourceData["udp-ports"],
fullDomain: resourceData["full-domain"] || null,
@@ -257,7 +292,10 @@ export async function updatePrivateResources(
pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
authDaemonMode:
resourceData["auth-daemon"]?.mode || "native",
authDaemonPort: resourceData["auth-daemon"]?.port || 22123
authDaemonPort: resourceData["auth-daemon"]?.port || 22123,
status: resourceStatusFromSite,
networkId: isInference ? null : undefined,
requiresExitNodeConnection: isInference
})
.where(
eq(
@@ -269,7 +307,19 @@ export async function updatePrivateResources(
const siteResourceId = existingResource.siteResourceId;
if (updatedResource.networkId) {
if (isInference) {
// inference resources are not attached to any site network
if (existingResource.networkId) {
await trx
.delete(siteNetworks)
.where(
eq(
siteNetworks.networkId,
existingResource.networkId
)
);
}
} else if (updatedResource.networkId) {
await trx
.delete(siteNetworks)
.where(
@@ -284,6 +334,28 @@ export async function updatePrivateResources(
}
}
await syncInferenceAiConfig({
orgId,
trx,
mode: resourceData.mode,
scope: "site",
siteResourceId,
providers: resourceData["ai-providers"].map((p) => ({
provider: p.provider,
accessMode: p["access-mode"],
enabled: p.enabled,
models: p.models
}))
});
await syncAiBudgets({
orgId,
trx,
scope: "site",
siteResourceId,
budgets: resourceData["ai-budget"]
});
await trx
.delete(clientSiteResources)
.where(eq(clientSiteResources.siteResourceId, siteResourceId));
@@ -406,6 +478,13 @@ export async function updatePrivateResources(
);
}
const labelIds = await getOrCreateLabelIds(
orgId,
resourceData.labels,
trx
);
await syncSiteResourceLabels(siteResourceId, labelIds, trx);
results.push({
newSiteResource: updatedResource,
oldSiteResource: existingResource,
@@ -413,6 +492,34 @@ export async function updatePrivateResources(
oldSites: existingSiteIds
});
} else {
// create a brand new resource
if (build == "saas") {
const usage = await usageService.getUsage(
orgId,
LimitId.PRIVATE_RESOURCES
);
if (!usage) {
throw new Error(
`Usage data not found for org ${orgId} and limit ${LimitId.PRIVATE_RESOURCES}`
);
}
const rejectResource = await usageService.checkLimitSet(
orgId,
LimitId.PRIVATE_RESOURCES,
{
...usage,
instantaneousValue: (usage.instantaneousValue || 0) + 1
} // We need to add one to know if we are violating the limit
);
if (rejectResource) {
throw new Error(
"Private resource limit exceeded. Please upgrade your plan."
);
}
}
let aliasAddress: string | null = null;
let releaseAliasLock: (() => Promise<void>) | null = null;
if (
@@ -428,25 +535,55 @@ export async function updatePrivateResources(
releaseAliasLock = release;
}
const isInference = resourceData.mode === "inference";
let domainInfo:
| { subdomain: string | null; domainId: string }
| undefined;
if (resourceData["full-domain"] && resourceData.mode === "http") {
if (
resourceData["full-domain"] &&
(resourceData.mode === "http" || isInference)
) {
domainInfo = await getDomainForSiteResource(
undefined,
resourceData["full-domain"],
orgId,
isInference,
trx
);
}
const [network] = await trx
.insert(networks)
.values({
scope: "resource",
orgId: orgId
})
.returning();
if (resourceData.alias) {
const [aliasConflict] = await trx
.select({
siteResourceId: siteResources.siteResourceId
})
.from(siteResources)
.where(
and(
eq(siteResources.orgId, orgId),
eq(siteResources.alias, resourceData.alias)
)
)
.limit(1);
if (aliasConflict) {
throw new Error(
`Alias ${resourceData.alias} already in use by another site resource in org ${orgId}`
);
}
}
let network: typeof networks.$inferSelect | undefined;
if (!isInference) {
[network] = await trx
.insert(networks)
.values({
scope: "resource",
orgId: orgId
})
.returning();
}
// Create new resource
const [newResource] = await trx
@@ -454,27 +591,28 @@ export async function updatePrivateResources(
.values({
orgId: orgId,
niceId: resourceNiceId,
networkId: network.networkId,
defaultNetworkId: network.networkId,
networkId: network ? network.networkId : null,
defaultNetworkId: network ? network.networkId : null,
name: resourceData.name || resourceNiceId,
mode: resourceData.mode,
ssl: resourceData.ssl,
ssl: resourceSsl,
scheme: resourceData.scheme,
destination: resourceData.destination,
destinationPort: resourceData["destination-port"],
enabled: true, // hardcoded for now
// enabled: resourceData.enabled ?? true,
enabled: resourceEnabled,
alias: resourceData.alias || null,
aliasAddress: aliasAddress,
disableIcmp:
resourceData["disable-icmp"] ||
(resourceData.mode == "http" ? true : false), // default to true for http resources, otherwise false
(resourceData.mode == "http" || isInference
? true
: false), // default to true for http/inference resources, otherwise false
tcpPortRangeString:
resourceData.mode == "http"
resourceData.mode == "http" || isInference
? "443,80"
: resourceData["tcp-ports"],
udpPortRangeString:
resourceData.mode == "http"
resourceData.mode == "http" || isInference
? ""
: resourceData["udp-ports"],
fullDomain: resourceData["full-domain"] || null,
@@ -483,7 +621,9 @@ export async function updatePrivateResources(
pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
authDaemonMode:
resourceData["auth-daemon"]?.mode || "native",
authDaemonPort: resourceData["auth-daemon"]?.port || 22123
authDaemonPort: resourceData["auth-daemon"]?.port || 22123,
status: resourceStatusFromSite,
requiresExitNodeConnection: isInference
})
.returning();
@@ -491,13 +631,37 @@ export async function updatePrivateResources(
const siteResourceId = newResource.siteResourceId;
for (const site of allSites) {
await trx.insert(siteNetworks).values({
siteId: site.siteId,
networkId: network.networkId
});
if (network) {
for (const site of allSites) {
await trx.insert(siteNetworks).values({
siteId: site.siteId,
networkId: network.networkId
});
}
}
await syncInferenceAiConfig({
orgId,
trx,
mode: resourceData.mode,
scope: "site",
siteResourceId,
providers: resourceData["ai-providers"].map((p) => ({
provider: p.provider,
accessMode: p["access-mode"],
enabled: p.enabled,
models: p.models
}))
});
await syncAiBudgets({
orgId,
trx,
scope: "site",
siteResourceId,
budgets: resourceData["ai-budget"]
});
const [adminRole] = await trx
.select()
.from(roles)
@@ -609,6 +773,15 @@ export async function updatePrivateResources(
`Created new client resource ${newResource.name} (${newResource.siteResourceId}) for org ${orgId}`
);
await usageService.add(orgId, LimitId.PRIVATE_RESOURCES, 1, trx);
const labelIds = await getOrCreateLabelIds(
orgId,
resourceData.labels,
trx
);
await syncSiteResourceLabels(siteResourceId, labelIds, trx);
results.push({
newSiteResource: newResource,
newSites: allSites,
+220 -83
View File
@@ -1,56 +1,60 @@
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { createCertificate } from "@server/routers/certificates/createCertificate";
import { hashPassword } from "@server/auth/password";
import { generateId } from "@server/auth/sessions/app";
import { build } from "@server/build";
import {
domains,
domainNamespaces,
domains,
orgDomains,
Resource,
resourceHeaderAuth,
resourceHeaderAuthExtendedCompatibility,
resourcePassword,
resourcePincode,
resourcePolicies,
resourcePolicyHeaderAuth,
resourcePolicyPassword,
resourcePolicyPincode,
resourcePolicyRules,
resourcePolicyWhiteList,
resourceRules,
resources,
resourceWhitelist,
roleActions,
rolePolicies,
roleResources,
roles,
Site,
sites,
Target,
TargetHealthCheck,
targetHealthCheck,
targets,
Transaction,
userOrgs,
userPolicies,
userResources,
users,
resourcePolicies,
resourcePolicyPassword,
resourcePolicyPincode,
resourcePolicyHeaderAuth,
resourcePolicyRules,
resourcePolicyWhiteList,
rolePolicies,
userPolicies
type ResourceRule
} from "@server/db";
import { resources, targets, sites } from "@server/db";
import { eq, and, asc, or, ne, count, isNotNull } from "drizzle-orm";
import {
Config,
ConfigSchema,
isTargetsOnlyResource,
TargetData
} from "./types";
import logger from "@server/logger";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { pickPort } from "@server/routers/target/helpers";
import { resourcePassword } from "@server/db";
import { getUniqueResourcePolicyName } from "@server/db/names";
import { hashPassword } from "@server/auth/password";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
import { isValidRegionId } from "@server/db/regions";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { fireHealthCheckUnknownAlert } from "@server/lib/alerts";
import { tierMatrix } from "../billing/tierMatrix";
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
import { build } from "@server/build";
import { encrypt } from "@server/lib/crypto";
import { generateId } from "@server/auth/sessions/app";
import serverConfig from "@server/lib/config";
import { encrypt } from "@server/lib/crypto";
import logger from "@server/logger";
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
import { pickPort } from "@server/routers/target/helpers";
import { and, asc, eq, isNotNull, ne, or } from "drizzle-orm";
import { tierMatrix } from "../billing/tierMatrix";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
import { Config, isTargetsOnlyResource, TargetData } from "./types";
import { getOrCreateLabelIds, syncResourceLabels } from "./labels";
import { LimitId } from "../billing";
import { usageService } from "../billing/usageService";
import { syncInferenceAiConfig } from "./aiProviders";
import { syncAiBudgets } from "./aiBudgets";
export type PublicResourcesResults = {
proxyResource: Resource;
@@ -71,19 +75,40 @@ export async function updatePublicResources(
)) {
const targetsToUpdate: Target[] = [];
const healthchecksToUpdate: TargetHealthCheck[] = [];
let resource: Resource;
let resourceStatusFromSite: "approved" | "pending" = "approved";
let providedSite: Partial<Site> | undefined;
if (siteId) {
// Use the provided siteId directly, but verify it belongs to the org
[providedSite] = await trx
.select({
siteId: sites.siteId,
type: sites.type,
status: sites.status
})
.from(sites)
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
.limit(1);
resourceStatusFromSite = providedSite?.status ?? "approved";
}
async function createTarget( // reusable function to create a target
resourceId: number,
targetData: TargetData
) {
const targetSiteId = targetData.site;
let site;
let site: Partial<Site> | undefined;
if (targetSiteId) {
// Look up site by niceId
[site] = await trx
.select({ siteId: sites.siteId, type: sites.type })
.select({
siteId: sites.siteId,
type: sites.type,
status: sites.status
})
.from(sites)
.where(
and(
@@ -92,15 +117,9 @@ export async function updatePublicResources(
)
)
.limit(1);
} else if (siteId) {
} else if (siteId && providedSite) {
// Use the provided siteId directly, but verify it belongs to the org
[site] = await trx
.select({ siteId: sites.siteId, type: sites.type })
.from(sites)
.where(
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
)
.limit(1);
site = providedSite;
} else {
throw new Error(`Target site is required`);
}
@@ -136,7 +155,7 @@ export async function updatePublicResources(
.insert(targets)
.values({
resourceId: resourceId,
siteId: site.siteId,
siteId: site.siteId!,
ip: targetData.hostname,
mode: resourceData.mode as Target["mode"],
method: targetData.method,
@@ -169,7 +188,7 @@ export async function updatePublicResources(
.insert(targetHealthCheck)
.values({
name: `${targetData.hostname}:${targetData.port}`,
siteId: site.siteId,
siteId: site.siteId!,
targetId: newTarget.targetId,
orgId: orgId,
hcEnabled: healthcheckData?.enabled || false,
@@ -227,7 +246,10 @@ export async function updatePublicResources(
const resourceEnabled =
resourceData.enabled == undefined || resourceData.enabled == null
? true
: resourceData.enabled;
: resourceStatusFromSite === "pending"
? false
: resourceData.enabled;
const resourceSsl =
resourceData.ssl == undefined || resourceData.ssl == null
? true
@@ -237,18 +259,6 @@ export async function updatePublicResources(
headers = JSON.stringify(resourceData.headers);
}
if (["ssh", "rdp", "vnc"].includes(resourceData.mode || "")) {
const isLicensed = await isLicensedOrSubscribed(
orgId,
tierMatrix.advancedPublicResources
);
if (!isLicensed) {
throw new Error(
"Your current subscription does not support browser gateway resources. Please upgrade to access this feature."
);
}
}
if (resourceData.policy) {
const isLicensed = await isLicensedOrSubscribed(
orgId,
@@ -264,7 +274,9 @@ export async function updatePublicResources(
if (existingResource) {
let domain;
if (
["http", "ssh", "rdp", "vnc"].includes(resourceData.mode || "")
["http", "ssh", "rdp", "vnc", "inference"].includes(
resourceData.mode || ""
)
) {
if (resourceData["full-domain"]?.startsWith("*.")) {
const isLicensed = await isLicensedOrSubscribed(
@@ -282,6 +294,7 @@ export async function updatePublicResources(
existingResource.resourceId,
resourceData["full-domain"]!,
orgId,
resourceData.mode === "inference",
trx
);
@@ -303,7 +316,7 @@ export async function updatePublicResources(
const isLicensed = await isLicensedOrSubscribed(
orgId,
tierMatrix.maintencePage
tierMatrix.maintenancePage
);
if (!isLicensed) {
resourceData.maintenance = undefined;
@@ -348,14 +361,22 @@ export async function updatePublicResources(
name: resourceData.name || "Unnamed Resource",
mode: resourceData.mode,
proxyPort: ["http", "ssh", "rdp", "vnc"].includes(
resourceData.mode || ""
)
proxyPort: [
"http",
"ssh",
"rdp",
"vnc",
"inference"
].includes(resourceData.mode || "")
? null
: resourceData["proxy-port"],
fullDomain: ["http", "ssh", "rdp", "vnc"].includes(
resourceData.mode || ""
)
fullDomain: [
"http",
"ssh",
"rdp",
"vnc",
"inference"
].includes(resourceData.mode || "")
? resourceData["full-domain"]
: null,
subdomain: domain ? domain.subdomain : null,
@@ -403,7 +424,8 @@ export async function updatePublicResources(
? (resourceData["proxy-protocol-version"] ??
1)
: 1,
resourcePolicyId: sharedPolicy.resourcePolicyId
resourcePolicyId: sharedPolicy.resourcePolicyId,
status: resourceStatusFromSite
})
.where(
eq(
@@ -543,14 +565,23 @@ export async function updatePublicResources(
.update(resources)
.set({
name: resourceData.name || "Unnamed Resource",
proxyPort: ["http", "ssh", "rdp", "vnc"].includes(
resourceData.mode || ""
)
mode: resourceData.mode,
proxyPort: [
"http",
"ssh",
"rdp",
"vnc",
"inference"
].includes(resourceData.mode || "")
? null
: resourceData["proxy-port"],
fullDomain: ["http", "ssh", "rdp", "vnc"].includes(
resourceData.mode || ""
)
fullDomain: [
"http",
"ssh",
"rdp",
"vnc",
"inference"
].includes(resourceData.mode || "")
? resourceData["full-domain"]
: null,
subdomain: domain ? domain.subdomain : null,
@@ -587,7 +618,8 @@ export async function updatePublicResources(
authDaemonPort:
resourceData["auth-daemon"]?.port || 22123,
resourcePolicyId: null,
defaultResourcePolicyId: inlinePolicyId
defaultResourcePolicyId: inlinePolicyId,
status: resourceStatusFromSite
})
.where(
eq(
@@ -649,6 +681,30 @@ export async function updatePublicResources(
trx
);
}
await syncInferenceAiConfig({
orgId,
trx,
mode: resourceData.mode || "",
scope: "public",
resourceId: existingResource.resourceId,
providers: (resourceData["ai-providers"] || []).map(
(p) => ({
provider: p.provider,
accessMode: p["access-mode"],
enabled: p.enabled,
models: p.models
})
)
});
await syncAiBudgets({
orgId,
trx,
scope: "public",
resourceId: existingResource.resourceId,
budgets: resourceData["ai-budget"] || []
});
}
const existingResourceTargets = await trx
@@ -729,7 +785,7 @@ export async function updatePublicResources(
: undefined),
rewritePathType: targetData["rewrite-match"],
priority: targetData.priority,
mode: resourceData.mode
mode: resourceData.mode as Target["mode"]
})
.where(eq(targets.targetId, existingTarget.targetId))
.returning();
@@ -904,7 +960,7 @@ export async function updatePublicResources(
.update(resourceRules)
.set({
action: getRuleAction(rule.action),
match: rule.match.toUpperCase(),
match: rule.match.toUpperCase() as ResourceRule["match"],
value: getRuleValue(
rule.match.toUpperCase(),
rule.value
@@ -923,7 +979,7 @@ export async function updatePublicResources(
await trx.insert(resourceRules).values({
resourceId: existingResource.resourceId,
action: getRuleAction(rule.action),
match: rule.match.toUpperCase(),
match: rule.match.toUpperCase() as ResourceRule["match"],
value: getRuleValue(
rule.match.toUpperCase(),
rule.value
@@ -1005,9 +1061,38 @@ export async function updatePublicResources(
logger.debug(`Updated resource ${existingResource.resourceId}`);
} else {
// create a brand new resource
if (build === "saas") {
const usage = await usageService.getUsage(
orgId,
LimitId.PUBLIC_RESOURCES
);
if (!usage) {
throw new Error(
`Usage data not found for org ${orgId} and limit ${LimitId.PUBLIC_RESOURCES}`
);
}
const rejectResource = await usageService.checkLimitSet(
orgId,
LimitId.PUBLIC_RESOURCES,
{
...usage,
instantaneousValue: (usage.instantaneousValue || 0) + 1
} // We need to add one to know if we are violating the limit
);
if (rejectResource) {
throw new Error(
"Public resource limit exceeded. Please upgrade your plan."
);
}
}
let domain;
if (
["http", "ssh", "rdp", "vnc"].includes(resourceData.mode || "")
["http", "ssh", "rdp", "vnc", "inference"].includes(
resourceData.mode || ""
)
) {
if (resourceData["full-domain"]?.startsWith("*.")) {
const isLicensed = await isLicensedOrSubscribed(
@@ -1025,6 +1110,7 @@ export async function updatePublicResources(
undefined,
resourceData["full-domain"]!,
orgId,
resourceData.mode === "inference",
trx
);
@@ -1037,7 +1123,7 @@ export async function updatePublicResources(
const isLicensed = await isLicensedOrSubscribed(
orgId,
tierMatrix.maintencePage
tierMatrix.maintenancePage
);
if (!isLicensed) {
resourceData.maintenance = undefined;
@@ -1101,16 +1187,25 @@ export async function updatePublicResources(
.values({
orgId,
niceId: resourceNiceId,
status: resourceStatusFromSite,
name: resourceData.name || "Unnamed Resource",
mode: resourceData.mode,
proxyPort: ["http", "ssh", "rdp", "vnc"].includes(
resourceData.mode || ""
)
proxyPort: [
"http",
"ssh",
"rdp",
"vnc",
"inference"
].includes(resourceData.mode || "")
? null
: resourceData["proxy-port"],
fullDomain: ["http", "ssh", "rdp", "vnc"].includes(
resourceData.mode || ""
)
fullDomain: [
"http",
"ssh",
"rdp",
"vnc",
"inference"
].includes(resourceData.mode || "")
? resourceData["full-domain"]
: null,
subdomain: domain ? domain.subdomain : null,
@@ -1165,6 +1260,28 @@ export async function updatePublicResources(
resource = newResource;
await syncInferenceAiConfig({
orgId,
trx,
mode: resourceData.mode || "",
scope: "public",
resourceId: newResource.resourceId,
providers: (resourceData["ai-providers"] || []).map((p) => ({
provider: p.provider,
accessMode: p["access-mode"],
enabled: p.enabled,
models: p.models
}))
});
await syncAiBudgets({
orgId,
trx,
scope: "public",
resourceId: newResource.resourceId,
budgets: resourceData["ai-budget"] || []
});
await trx.insert(roleResources).values({
roleId: adminRole.roleId,
resourceId: newResource.resourceId
@@ -1260,7 +1377,7 @@ export async function updatePublicResources(
await trx.insert(resourceRules).values({
resourceId: newResource.resourceId,
action: getRuleAction(rule.action),
match: rule.match.toUpperCase(),
match: rule.match.toUpperCase() as ResourceRule["match"],
value: getRuleValue(
rule.match.toUpperCase(),
rule.value
@@ -1294,9 +1411,20 @@ export async function updatePublicResources(
await createTarget(newResource.resourceId, targetData);
}
await usageService.add(orgId, LimitId.PUBLIC_RESOURCES, 1, trx);
logger.debug(`Created resource ${newResource.resourceId}`);
}
if (!isTargetsOnlyResource(resourceData)) {
const labelIds = await getOrCreateLabelIds(
orgId,
resourceData.labels || [],
trx
);
await syncResourceLabels(resource.resourceId, labelIds, trx);
}
results.push({
proxyResource: resource,
targetsToUpdate,
@@ -1321,7 +1449,7 @@ function getRuleAction(input: string) {
function getRuleValue(match: string, value: string) {
// if the match is a country, uppercase the value
if (match == "COUNTRY") {
if (match === "COUNTRY" || match === "COUNTRY_IS_NOT") {
return value.toUpperCase();
}
return value;
@@ -2011,6 +2139,7 @@ export async function getDomain(
resourceId: number | undefined,
fullDomain: string,
orgId: string,
isInference: boolean,
trx: Transaction
) {
const [fullDomainExists] = await trx
@@ -2020,6 +2149,14 @@ export async function getDomain(
and(
eq(resources.fullDomain, fullDomain),
eq(resources.orgId, orgId),
// Inference resources route through the central AI gateway
// rather than normal target-based proxying, so they're
// allowed to share a full-domain with a non-inference
// resource (and vice versa) - only conflicts within the
// same routing category are rejected.
isInference
? ne(resources.mode, "inference")
: eq(resources.mode, "inference"),
resourceId
? ne(resources.resourceId, resourceId)
: isNotNull(resources.resourceId)
+9 -15
View File
@@ -1,7 +1,5 @@
import {
db,
idp,
idpOrg,
resourcePolicies,
resourcePolicyHeaderAuth,
resourcePolicyPassword,
@@ -20,6 +18,7 @@ import { Config, ResourcePolicyData } from "./types";
import logger from "@server/logger";
import { getUniqueResourcePolicyName } from "@server/db/names";
import { hashPassword } from "@server/auth/password";
import { idpExistsForOrg } from "@server/lib/idp/idpExistsForOrg";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "../billing/tierMatrix";
@@ -71,19 +70,13 @@ export async function updateResourcePolicies(
// Validate auto-login-idp if provided
if (policyData["auto-login-idp"]) {
const [provider] = await trx
.select()
.from(idp)
.innerJoin(idpOrg, eq(idpOrg.idpId, idp.idpId))
.where(
and(
eq(idp.idpId, policyData["auto-login-idp"]),
eq(idpOrg.orgId, orgId)
)
)
.limit(1);
const providerExists = await idpExistsForOrg(
policyData["auto-login-idp"],
orgId,
trx
);
if (!provider) {
if (!providerExists) {
throw new Error(
`Identity provider not found for policy '${policyNiceId}' in this organization`
);
@@ -347,12 +340,13 @@ function getRuleAction(input: string): "ACCEPT" | "DROP" | "PASS" {
function getRuleMatch(
input: string
): "CIDR" | "IP" | "PATH" | "COUNTRY" | "ASN" | "REGION" {
): "CIDR" | "IP" | "PATH" | "COUNTRY" | "COUNTRY_IS_NOT" | "ASN" | "REGION" {
return input.toUpperCase() as
| "CIDR"
| "IP"
| "PATH"
| "COUNTRY"
| "COUNTRY_IS_NOT"
| "ASN"
| "REGION";
}
+133 -13
View File
@@ -5,6 +5,11 @@ import { MaintenanceSchema } from "#dynamic/lib/blueprints/MaintenanceSchema";
import { isValidRegionId } from "@server/db/regions";
import { wildcardSubdomainSchema } from "@server/lib/schemas";
import config from "@server/lib/config";
import {
aiBudgetEnforcementSchema,
aiBudgetPeriodSchema,
aiBudgetUnitSchema
} from "@server/routers/aiBudget/validation";
const maxmindDbPath = config.getRawConfig().server.maxmind_db_path;
const maxmindAsnPath = config.getRawConfig().server.maxmind_asn_path;
@@ -28,7 +33,7 @@ export const TargetHealthCheckSchema = z.object({
hostname: z.string(),
port: z.int().min(1).max(65535),
enabled: z.boolean().optional().default(true),
path: z.string().optional(),
path: z.string().optional().default("/"),
scheme: z.string().optional(),
mode: z.string().default("http"),
interval: z.int().default(30),
@@ -183,6 +188,56 @@ export const HeaderSchema = z.object({
value: z.string().min(1)
});
export const AiProviderAttachmentSchema = z
.object({
provider: z.string().min(1),
"access-mode": z
.enum(["inherit", "select"])
.optional()
.default("inherit"),
enabled: z.boolean().optional().default(true),
models: z.array(z.string()).optional().default([])
})
.refine(
(provider) => {
if (provider.models.length === 0) {
return true;
}
return provider["access-mode"] === "select";
},
{
path: ["models"],
error: "'models' can only be set on a provider with access-mode 'select'"
}
);
export const AiBudgetSchema = z.object({
amount: z.number().positive(),
unit: aiBudgetUnitSchema,
period: aiBudgetPeriodSchema.optional().default("monthly"),
enforcement: aiBudgetEnforcementSchema.optional().default("hard"),
enabled: z.boolean().optional().default(true)
});
const aiBudgetArraySchema = z.array(AiBudgetSchema).refine(
(budgets) => {
const keys = budgets.map((b) => `${b.unit}::${b.period}`);
return keys.length === new Set(keys).size;
},
{
message:
"'ai-budget' entries must not overlap: only one budget per unit/period combination is allowed"
}
);
// No default here: an object with only 'targets' set must remain
// recognized as a targets-only resource by isTargetsOnlyResource().
export const AiBudgetListSchema = aiBudgetArraySchema.optional();
export const AiBudgetListSchemaWithDefault = aiBudgetArraySchema
.optional()
.default([]);
export const AuthDaemonSchema = z
.object({
pam: z.enum(["passthrough", "push"]).optional().default("passthrough"),
@@ -209,7 +264,9 @@ export const PublicResourceSchema = z
protocol: z
.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"])
.optional(), // this was the old one and is now DEPRECATED in favor of the mode
mode: z.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"]).optional(),
mode: z
.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc", "inference"])
.optional(),
policy: z.string().optional(),
ssl: z.boolean().optional(),
scheme: z.enum(["http", "https"]).optional(),
@@ -225,7 +282,10 @@ export const PublicResourceSchema = z
maintenance: MaintenanceSchema.optional(),
"auth-daemon": AuthDaemonSchema.optional(),
"proxy-protocol": z.boolean().optional(),
"proxy-protocol-version": z.int().min(1).optional()
"proxy-protocol-version": z.int().min(1).optional(),
labels: z.array(z.string().min(1)).optional(),
"ai-providers": z.array(AiProviderAttachmentSchema).optional(),
"ai-budget": AiBudgetListSchema
})
.refine(
(resource) => {
@@ -314,11 +374,13 @@ export const PublicResourceSchema = z
return true;
}
// If protocol/mode is http, ssh, rdp, or vnc, it must have a full-domain
// If protocol/mode is http, ssh, rdp, vnc, or inference, it must have a full-domain
const effectiveProtocol = resource.mode ?? resource.protocol;
if (
effectiveProtocol !== undefined &&
["http", "ssh", "rdp", "vnc"].includes(effectiveProtocol)
["http", "ssh", "rdp", "vnc", "inference"].includes(
effectiveProtocol
)
) {
return (
resource["full-domain"] !== undefined &&
@@ -329,7 +391,43 @@ export const PublicResourceSchema = z
},
{
path: ["full-domain"],
error: "When protocol is 'http', 'ssh', 'rdp', or 'vnc', a 'full-domain' must be provided"
error: "When protocol is 'http', 'ssh', 'rdp', 'vnc', or 'inference', a 'full-domain' must be provided"
}
)
.refine(
(resource) => {
if (isTargetsOnlyResource(resource)) {
return true;
}
const effectiveMode = resource.mode ?? resource.protocol;
if (effectiveMode !== "inference") {
return true;
}
return resource.targets.every((target) => target == null);
},
{
path: ["targets"],
error: "When mode is 'inference', 'targets' must not be provided"
}
)
.refine(
(resource) => {
if (isTargetsOnlyResource(resource)) {
return true;
}
const effectiveMode = resource.mode ?? resource.protocol;
if (effectiveMode === "inference") {
return true;
}
return (resource["ai-providers"]?.length ?? 0) === 0;
},
{
path: ["ai-providers"],
error: "'ai-providers' can only be set when mode is 'inference'"
}
)
.refine(
@@ -463,14 +561,14 @@ export function isTargetsOnlyResource(resource: any): boolean {
export const PrivateResourceSchema = z
.object({
name: z.string().min(1).max(255),
mode: z.enum(["host", "cidr", "http", "ssh"]),
mode: z.enum(["host", "cidr", "http", "ssh", "inference"]),
site: z.string().optional(), // DEPRECATED IN FAVOR OF sites
sites: z.array(z.string()).optional().default([]),
// protocol: z.enum(["tcp", "udp"]).optional(),
// proxyPort: z.int().positive().optional(),
"destination-port": z.int().positive().optional(),
destination: z.string().min(1).optional(),
// enabled: z.boolean().default(true),
enabled: z.boolean().default(true),
"tcp-ports": portRangeStringSchema.optional().default("*"),
"udp-ports": portRangeStringSchema.optional().default("*"),
"disable-icmp": z.boolean().optional().default(false),
@@ -493,16 +591,26 @@ export const PrivateResourceSchema = z
}),
users: z.array(z.string()).optional().default([]),
machines: z.array(z.string()).optional().default([]),
"auth-daemon": AuthDaemonSchema.optional()
labels: z.array(z.string().min(1)).optional().default([]),
"auth-daemon": AuthDaemonSchema.optional(),
"ai-providers": z
.array(AiProviderAttachmentSchema)
.optional()
.default([]),
"ai-budget": AiBudgetListSchemaWithDefault
})
.refine(
(data) => {
// destination is optional only for ssh+native; required for everything else
// destination is optional only for ssh+native or inference; required for everything else
const isNativeSSH =
data.mode === "ssh" &&
(data["auth-daemon"] === undefined ||
data["auth-daemon"].mode === "native");
if (!isNativeSSH && !data.destination) {
if (
data.mode !== "inference" &&
!isNativeSSH &&
!data.destination
) {
return false;
}
return true;
@@ -510,7 +618,19 @@ export const PrivateResourceSchema = z
{
path: ["destination"],
message:
"destination is required unless mode is 'ssh' with auth-daemon mode 'native'"
"destination is required unless mode is 'ssh' with auth-daemon mode 'native', or mode is 'inference'"
}
)
.refine(
(data) => {
if (data.mode === "inference") {
return true;
}
return (data["ai-providers"]?.length ?? 0) === 0;
},
{
path: ["ai-providers"],
error: "'ai-providers' can only be set when mode is 'inference'"
}
)
.refine(
@@ -632,7 +752,6 @@ export const ResourcePolicySchema = z.object({
})
)
)
.max(50)
.transform((v) => v.map((e) => e.toLowerCase()))
.optional()
.default([]),
@@ -838,3 +957,4 @@ export type Target = z.infer<typeof TargetSchema>;
export type Resource = z.infer<typeof PublicResourceSchema>;
export type Config = z.infer<typeof ConfigSchema>;
export type BlueprintResourcePolicy = z.infer<typeof ResourcePolicySchema>;
export type BlueprintAiBudget = z.infer<typeof AiBudgetSchema>;
+15 -15
View File
@@ -10,12 +10,12 @@ export const localCache = new NodeCache({
});
// Log cache statistics periodically for monitoring
setInterval(() => {
const stats = localCache.getStats();
logger.debug(
`Local cache stats - Keys: ${stats.keys}, Hits: ${stats.hits}, Misses: ${stats.misses}, Hit rate: ${stats.hits > 0 ? ((stats.hits / (stats.hits + stats.misses)) * 100).toFixed(2) : 0}%`
);
}, 300000); // Every 5 minutes
// setInterval(() => {
// const stats = localCache.getStats();
// logger.debug(
// `Local cache stats - Keys: ${stats.keys}, Hits: ${stats.hits}, Misses: ${stats.misses}, Hit rate: ${stats.hits > 0 ? ((stats.hits / (stats.hits + stats.misses)) * 100).toFixed(2) : 0}%`
// );
// }, 300000); // Every 5 minutes
/**
* Adaptive cache that uses Redis when available in multi-node environments,
@@ -34,9 +34,9 @@ class AdaptiveCache {
// Use local cache as fallback or primary
const success = localCache.set(key, value, effectiveTtl || 0);
if (success) {
logger.debug(`Set key in local cache: ${key}`);
}
// if (success) {
// logger.debug(`Set key in local cache: ${key}`);
// }
return success;
}
@@ -48,11 +48,11 @@ class AdaptiveCache {
async get<T = any>(key: string): Promise<T | undefined> {
// Use local cache as fallback or primary
const value = localCache.get<T>(key);
if (value !== undefined) {
logger.debug(`Cache hit in local cache: ${key}`);
} else {
logger.debug(`Cache miss in local cache: ${key}`);
}
// if (value !== undefined) {
// logger.debug(`Cache hit in local cache: ${key}`);
// } else {
// logger.debug(`Cache miss in local cache: ${key}`);
// }
return value;
}
@@ -168,5 +168,5 @@ class AdaptiveCache {
// Export singleton instance
export const cache = new AdaptiveCache();
export const regionalCache = cache; // Alias for compatability with the private version
export const regionalCache = cache; // Alias for compatibility with the private version
export default cache;
-14
View File
@@ -339,19 +339,6 @@ export async function calculateUserClientsForOrgs(
continue;
}
// Get exit nodes for this org
const exitNodesList = await getExitNodes(orgId);
if (exitNodesList.length === 0) {
logger.warn(
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no exit nodes found`
);
continue;
}
const randomExitNode =
exitNodesList[Math.floor(Math.random() * exitNodesList.length)];
// Get next available subnet
const { value: newSubnet, release: releaseSubnetLock } =
await getNextAvailableClientSubnet(orgId, trx);
@@ -370,7 +357,6 @@ export async function calculateUserClientsForOrgs(
const newClientData: InferInsertModel<typeof clients> = {
userId,
orgId: userOrg.orgId,
exitNodeId: randomExitNode.exitNodeId,
name: olm.name || "User Client",
subnet: updatedSubnet,
olmId: olm.olmId,
+222 -12
View File
@@ -1,16 +1,226 @@
import config from "@server/lib/config";
import { certificates, db } from "@server/db";
import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm";
import { decrypt } from "@server/lib/crypto";
import logger from "@server/logger";
import { regionalCache as cache } from "#dynamic/lib/cache";
import { build } from "@server/build";
// Define the return type for clarity and type safety
export type CertificateResult = {
id: number;
domain: string;
queriedDomain: string; // The domain that was originally requested (may differ for wildcards)
wildcard: boolean | null;
certFile: string | null;
keyFile: string | null;
expiresAt: number | null;
updatedAt?: number | null;
};
export async function getValidCertificatesForDomains(
domains: Set<string>,
useCache: boolean = true
): Promise<
Array<{
id: number;
domain: string;
wildcard: boolean | null;
certFile: string | null;
keyFile: string | null;
expiresAt: number | null;
updatedAt?: number | null;
}>
> {
return []; // stub
): Promise<Array<CertificateResult>> {
const finalResults: CertificateResult[] = [];
const domainsToQuery = new Set<string>();
// 1. Check cache first if enabled
if (useCache) {
for (const domain of domains) {
const cacheKey = `cert:${domain}`;
const cachedCert = await cache.get<CertificateResult>(cacheKey);
if (cachedCert) {
finalResults.push(cachedCert); // Valid cache hit
} else {
// Also check for a wildcard cache entry covering this domain's parent
const parts = domain.split(".");
let wildcardHit = false;
if (parts.length > 1) {
const parentDomain = parts.slice(1).join(".");
const wildcardCacheKey = `cert:*.${parentDomain}`;
const cachedWildcard =
await cache.get<CertificateResult>(wildcardCacheKey);
if (cachedWildcard) {
// Re-stamp queriedDomain so callers see the originally requested domain
finalResults.push({
...cachedWildcard,
queriedDomain: domain
});
wildcardHit = true;
}
}
if (!wildcardHit) {
domainsToQuery.add(domain); // Cache miss or expired
}
}
}
} else {
// If caching is disabled, add all domains to the query set
domains.forEach((d) => domainsToQuery.add(d));
}
// 2. If all domains were resolved from the cache, return early
if (domainsToQuery.size === 0) {
const decryptedResults = decryptFinalResults(
finalResults,
config.getRawConfig().server.secret!
);
return decryptedResults;
}
// 3. Prepare domains for the database query
const domainsToQueryArray = Array.from(domainsToQuery);
const parentDomainsToQuery = new Set<string>();
domainsToQueryArray.forEach((domain) => {
const parts = domain.split(".");
// A wildcard can only match a domain with at least two parts (e.g., example.com)
if (parts.length > 1) {
parentDomainsToQuery.add(parts.slice(1).join("."));
}
});
const parentDomainsArray = Array.from(parentDomainsToQuery);
// Build wildcard variants: for each parent domain "example.com", also query "*.example.com"
const wildcardPrefixedArray =
build != "saas" ? parentDomainsArray.map((d) => `*.${d}`) : [];
// 4. Build and execute a single, efficient Drizzle query
// This query fetches all potential exact and wildcard matches in one database round-trip.
const potentialCerts = await db
.select()
.from(certificates)
.where(
and(
eq(certificates.status, "valid"),
isNotNull(certificates.certFile),
isNotNull(certificates.keyFile),
or(
// Condition for exact matches on the requested domains
inArray(certificates.domain, domainsToQueryArray),
// Condition for wildcard matches on the parent domains (stored as "example.com" or "*.example.com")
parentDomainsArray.length > 0
? and(
inArray(certificates.domain, [
...parentDomainsArray,
...wildcardPrefixedArray
]),
eq(certificates.wildcard, true)
)
: // If there are no possible parent domains, this condition is false
sql`false`
)
)
);
// Helper to normalize a wildcard cert's domain to its bare parent domain (strips leading "*.")
const normalizeWildcardDomain = (domain: string): string =>
domain.startsWith("*.") ? domain.slice(2) : domain;
// 5. Process the database results, prioritizing exact matches over wildcards
const exactMatches = new Map<string, (typeof potentialCerts)[0]>();
const wildcardMatches = new Map<string, (typeof potentialCerts)[0]>();
for (const cert of potentialCerts) {
if (cert.wildcard) {
// Normalize to bare parent domain so lookups are consistent regardless of storage format
wildcardMatches.set(normalizeWildcardDomain(cert.domain), cert);
} else {
exactMatches.set(cert.domain, cert);
}
}
for (const domain of domainsToQuery) {
let foundCert: (typeof potentialCerts)[0] | undefined = undefined;
// Priority 1: Check for an exact match (non-wildcard)
if (exactMatches.has(domain)) {
foundCert = exactMatches.get(domain);
}
// Priority 2: Check for a wildcard certificate whose normalized domain equals the queried domain
else {
const normalizedDomain = normalizeWildcardDomain(domain);
if (wildcardMatches.has(normalizedDomain)) {
foundCert = wildcardMatches.get(normalizedDomain);
}
// Priority 3: Check for a wildcard match on the parent domain
else {
const parts = normalizedDomain.split(".");
if (parts.length > 1) {
const parentDomain = parts.slice(1).join(".");
if (wildcardMatches.has(parentDomain)) {
foundCert = wildcardMatches.get(parentDomain);
}
}
}
}
// If a certificate was found, format it, add to results, and cache it
if (foundCert) {
logger.debug(
`Creating result cert for ${domain} using cert from ${foundCert.domain}`
);
const resultCert: CertificateResult = {
id: foundCert.certId,
domain: foundCert.domain, // The actual domain of the cert record
queriedDomain: domain, // The domain that was originally requested
wildcard: foundCert.wildcard,
certFile: foundCert.certFile,
keyFile: foundCert.keyFile,
expiresAt: foundCert.expiresAt,
updatedAt: foundCert.updatedAt
};
finalResults.push(resultCert);
// Add to cache for future requests, using the *requested domain* as the key
if (useCache) {
const cacheKey = `cert:${domain}`;
await cache.set(cacheKey, resultCert, 180);
// Also cache wildcard certs under a pattern key so other subdomains
// can find them without a DB round-trip
if (resultCert.wildcard) {
const normalizedCertDomain = normalizeWildcardDomain(
resultCert.domain
);
const wildcardCacheKey = `cert:*.${normalizedCertDomain}`;
await cache.set(wildcardCacheKey, resultCert, 180);
}
}
}
}
const decryptedResults = decryptFinalResults(
finalResults,
config.getRawConfig().server.secret!
);
return decryptedResults;
}
function decryptFinalResults(
finalResults: CertificateResult[],
secret: string
): CertificateResult[] {
const validCertsDecrypted = finalResults.map((cert) => {
// Decrypt and save certificate file
const decryptedCert = decrypt(
cert.certFile!, // is not null from query
secret
);
// Decrypt and save key file
const decryptedKey = decrypt(cert.keyFile!, secret);
// Return only the certificate data without org information
return {
...cert,
certFile: decryptedCert,
keyFile: decryptedKey
};
});
return validCertsDecrypted;
}
+17 -4
View File
@@ -3,12 +3,14 @@ import { cleanUpOldLogs as cleanUpOldAccessLogs } from "#dynamic/lib/logAccessAu
import { cleanUpOldLogs as cleanUpOldActionLogs } from "#dynamic/middlewares/logActionAudit";
import { cleanUpOldLogs as cleanUpOldRequestLogs } from "@server/routers/badger/logRequestAudit";
import { cleanUpOldLogs as cleanUpOldConnectionLogs } from "#dynamic/routers/newt";
import { cleanUpOldLogs as cleanUpOldAiSessionLogs } from "@server/routers/aiGateway/logAiSession";
import { gt, or } from "drizzle-orm";
import { cleanUpOldFingerprintSnapshots } from "@server/routers/olm/fingerprintingUtils";
import { build } from "@server/build";
export function initLogCleanupInterval() {
if (build == "saas") { // skip log cleanup for saas builds
if (build == "saas") {
// skip log cleanup for saas builds
return null;
}
return setInterval(
@@ -23,7 +25,9 @@ export function initLogCleanupInterval() {
settingsLogRetentionDaysRequest:
orgs.settingsLogRetentionDaysRequest,
settingsLogRetentionDaysConnection:
orgs.settingsLogRetentionDaysConnection
orgs.settingsLogRetentionDaysConnection,
settingsLogRetentionDaysAISessions:
orgs.settingsLogRetentionDaysAISessions
})
.from(orgs)
.where(
@@ -31,7 +35,8 @@ export function initLogCleanupInterval() {
gt(orgs.settingsLogRetentionDaysAction, 0),
gt(orgs.settingsLogRetentionDaysAccess, 0),
gt(orgs.settingsLogRetentionDaysRequest, 0),
gt(orgs.settingsLogRetentionDaysConnection, 0)
gt(orgs.settingsLogRetentionDaysConnection, 0),
gt(orgs.settingsLogRetentionDaysAISessions, 0)
)
);
@@ -42,7 +47,8 @@ export function initLogCleanupInterval() {
settingsLogRetentionDaysAction,
settingsLogRetentionDaysAccess,
settingsLogRetentionDaysRequest,
settingsLogRetentionDaysConnection
settingsLogRetentionDaysConnection,
settingsLogRetentionDaysAISessions
} = org;
if (settingsLogRetentionDaysAction > 0) {
@@ -72,6 +78,13 @@ export function initLogCleanupInterval() {
settingsLogRetentionDaysConnection
);
}
if (settingsLogRetentionDaysAISessions > 0) {
await cleanUpOldAiSessionLogs(
orgId,
settingsLogRetentionDaysAISessions
);
}
}
await cleanUpOldFingerprintSnapshots(365);
+16
View File
@@ -18,3 +18,19 @@ export function canCompress(
return false;
}
}
// Whether this newt client understands `tlsCertId` references into the
// sync message's `certs` array, instead of requiring each target to carry
// its own inline `tlsCert`/`tlsKey` PEM data. Bump the version floor here to
// match whatever release first ships the newt-side support.
export function supportsCertReferences(
clientVersion: string | null | undefined
): boolean {
try {
if (!clientVersion) return false;
if (!semver.valid(clientVersion)) return false;
return semver.gte(clientVersion, "1.16.0");
} catch {
return false;
}
}

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