mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-11 06:58:28 +02:00
Compare commits
1 Commits
aig
..
7da28991d0
| Author | SHA1 | Date | |
|---|---|---|---|
| 7da28991d0 |
@@ -1,31 +0,0 @@
|
|||||||
---
|
|
||||||
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.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
alwaysApply: true
|
|
||||||
---
|
|
||||||
|
|
||||||
When adding submit buttons, don't change the text of the button during the loading state. Text should stay static and you should use the loading prop on the button.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
alwaysApply: true
|
|
||||||
---
|
|
||||||
|
|
||||||
When creating UI for popup dialogs or modals, use the Credenza componennt. This component is mobile responsive and works on desktop and wraps the dialog component and sheet into one.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
alwaysApply: true
|
|
||||||
---
|
|
||||||
|
|
||||||
Don't write or edit migrations in `server/setup` unless specificall instructed to do so.
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
---
|
|
||||||
alwaysApply: true
|
|
||||||
---
|
|
||||||
|
|
||||||
When writing TypeScript:
|
|
||||||
|
|
||||||
Prefer to use types instead of interfaces.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
alwaysApply: true
|
|
||||||
---
|
|
||||||
|
|
||||||
When creating forms, use React form for validation and use Zod schemas.
|
|
||||||
@@ -34,5 +34,3 @@ build.ts
|
|||||||
tsconfig.json
|
tsconfig.json
|
||||||
Dockerfile*
|
Dockerfile*
|
||||||
drizzle.config.ts
|
drizzle.config.ts
|
||||||
allowedDevOrigins.json
|
|
||||||
scratch/
|
|
||||||
|
|||||||
@@ -14,13 +14,12 @@ body:
|
|||||||
label: Environment
|
label: Environment
|
||||||
description: Please fill out the relevant details below for your environment.
|
description: Please fill out the relevant details below for your environment.
|
||||||
value: |
|
value: |
|
||||||
- OS Type & Version:
|
- OS Type & Version: (e.g., Ubuntu 22.04)
|
||||||
- Pangolin Version:
|
- Pangolin Version:
|
||||||
- Edition (Community or Enterprise):
|
|
||||||
- Gerbil Version:
|
- Gerbil Version:
|
||||||
- Traefik Version:
|
- Traefik Version:
|
||||||
- Newt Version:
|
- Newt Version:
|
||||||
- Client Version:
|
- Olm Version: (if applicable)
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
|||||||
+29
-19
@@ -1,42 +1,52 @@
|
|||||||
version: 2
|
version: 2
|
||||||
|
|
||||||
updates:
|
updates:
|
||||||
- package-ecosystem: "npm"
|
- package-ecosystem: "npm"
|
||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "daily"
|
interval: "daily"
|
||||||
open-pull-requests-limit: 1
|
|
||||||
groups:
|
groups:
|
||||||
npm-dependencies:
|
dev-patch-updates:
|
||||||
patterns:
|
dependency-type: "development"
|
||||||
- "*"
|
update-types:
|
||||||
|
- "patch"
|
||||||
|
dev-minor-updates:
|
||||||
|
dependency-type: "development"
|
||||||
|
update-types:
|
||||||
|
- "minor"
|
||||||
|
prod-patch-updates:
|
||||||
|
dependency-type: "production"
|
||||||
|
update-types:
|
||||||
|
- "patch"
|
||||||
|
prod-minor-updates:
|
||||||
|
dependency-type: "production"
|
||||||
|
update-types:
|
||||||
|
- "minor"
|
||||||
|
|
||||||
- package-ecosystem: "docker"
|
- package-ecosystem: "docker"
|
||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "daily"
|
interval: "daily"
|
||||||
open-pull-requests-limit: 1
|
|
||||||
groups:
|
groups:
|
||||||
docker-dependencies:
|
patch-updates:
|
||||||
patterns:
|
update-types:
|
||||||
- "*"
|
- "patch"
|
||||||
|
minor-updates:
|
||||||
|
update-types:
|
||||||
|
- "minor"
|
||||||
|
|
||||||
- package-ecosystem: "github-actions"
|
- package-ecosystem: "github-actions"
|
||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
open-pull-requests-limit: 1
|
|
||||||
groups:
|
|
||||||
github-actions-dependencies:
|
|
||||||
patterns:
|
|
||||||
- "*"
|
|
||||||
|
|
||||||
- package-ecosystem: "gomod"
|
- package-ecosystem: "gomod"
|
||||||
directory: "/install"
|
directory: "/install"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "daily"
|
interval: "daily"
|
||||||
open-pull-requests-limit: 1
|
|
||||||
groups:
|
groups:
|
||||||
go-install-dependencies:
|
patch-updates:
|
||||||
patterns:
|
update-types:
|
||||||
- "*"
|
- "patch"
|
||||||
|
minor-updates:
|
||||||
|
update-types:
|
||||||
|
- "minor"
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Monitor storage space
|
- name: Monitor storage space
|
||||||
run: |
|
run: |
|
||||||
@@ -77,7 +77,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||||
with:
|
with:
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
@@ -134,7 +134,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Monitor storage space
|
- name: Monitor storage space
|
||||||
run: |
|
run: |
|
||||||
@@ -149,7 +149,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||||
with:
|
with:
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
@@ -201,10 +201,10 @@ jobs:
|
|||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||||
with:
|
with:
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
@@ -256,7 +256,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Extract tag name
|
- name: Extract tag name
|
||||||
id: get-tag
|
id: get-tag
|
||||||
@@ -264,7 +264,7 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||||
with:
|
with:
|
||||||
go-version: 1.25
|
go-version: 1.25
|
||||||
|
|
||||||
@@ -407,7 +407,7 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry (for cosign)
|
- name: Login to GitHub Container Registry (for cosign)
|
||||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
with:
|
with:
|
||||||
node-version: '24'
|
node-version: '24'
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ jobs:
|
|||||||
skopeo --version
|
skopeo --version
|
||||||
|
|
||||||
- name: Install cosign
|
- name: Install cosign
|
||||||
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
||||||
|
|
||||||
- name: Input check
|
- name: Input check
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ jobs:
|
|||||||
stale:
|
stale:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
|
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||||
with:
|
with:
|
||||||
days-before-stale: 14
|
days-before-stale: 14
|
||||||
days-before-close: 14
|
days-before-close: 14
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
with:
|
with:
|
||||||
node-version: '24'
|
node-version: '24'
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Build Docker image sqlite
|
- name: Build Docker image sqlite
|
||||||
run: make dev-build-sqlite
|
run: make dev-build-sqlite
|
||||||
@@ -71,7 +71,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Build Docker image pg
|
- name: Build Docker image pg
|
||||||
run: make dev-build-pg
|
run: make dev-build-pg
|
||||||
|
|||||||
+2
-4
@@ -17,9 +17,9 @@ yarn-error.log*
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
*.db
|
*.db
|
||||||
*.sqlite*
|
*.sqlite
|
||||||
!Dockerfile.sqlite
|
!Dockerfile.sqlite
|
||||||
*.sqlite3*
|
*.sqlite3
|
||||||
*.log
|
*.log
|
||||||
.machinelogs*.json
|
.machinelogs*.json
|
||||||
*-audit.json
|
*-audit.json
|
||||||
@@ -54,5 +54,3 @@ hydrateSaas.ts
|
|||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
drizzle.config.ts
|
drizzle.config.ts
|
||||||
server/setup/migrations.ts
|
server/setup/migrations.ts
|
||||||
solo.yml
|
|
||||||
allowedDevOrigins.json
|
|
||||||
Vendored
+1
-4
@@ -18,8 +18,5 @@
|
|||||||
"[json]": {
|
"[json]": {
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
},
|
},
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true
|
||||||
"cSpell.words": [
|
|
||||||
"nessicary"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
+4
-4
@@ -1,5 +1,5 @@
|
|||||||
# FROM node:24.18.1-slim AS base
|
# FROM node:24-slim AS base
|
||||||
FROM public.ecr.aws/docker/library/node:24.18.1-slim AS base
|
FROM public.ecr.aws/docker/library/node:24-slim AS base
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -32,8 +32,8 @@ FROM base AS builder
|
|||||||
|
|
||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
# FROM node:24.18.1-slim AS runner
|
# FROM node:24-slim AS runner
|
||||||
FROM public.ecr.aws/docker/library/node:24.18.1-slim AS runner
|
FROM public.ecr.aws/docker/library/node:24-slim AS runner
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
FROM node:24.18.1-alpine
|
FROM node:24-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
</strong>
|
</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
Pangolin is an open-source, identity-based remote access platform built on WireGuard® that enables secure connectivity to infrastructure anywhere. It combines reverse-proxy and VPN capabilities into one platform, providing browser-based access to web applications and client-based access to private resources with NAT traversal, all with granular access control.
|
Pangolin is an open-source, 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.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -63,26 +63,11 @@ 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.
|
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%" />
|
<img src="public/screenshots/sites.png" alt="Sites" width="100%" />
|
||||||
|
|
||||||
### Browser-based reverse proxy access
|
### Browser-based reverse proxy access
|
||||||
|
|
||||||
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 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 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%" />
|
<img src="public/clip.gif" alt="Reverse proxy access" width="100%" />
|
||||||
|
|
||||||
@@ -90,35 +75,14 @@ Expose HTTPS web applications and connect to VNC, RDP, and SSH entirely in the b
|
|||||||
|
|
||||||
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.
|
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%" />
|
<img src="public/screenshots/private-resources.png" alt="Private resources" width="100%" />
|
||||||
|
|
||||||
### Give users and roles access to resources
|
### 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%" />
|
<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 Clients
|
||||||
|
|
||||||
Download the Pangolin client for your platform:
|
Download the Pangolin client for your platform:
|
||||||
@@ -143,7 +107,7 @@ the docs to illustrate some basic ideas.
|
|||||||
|
|
||||||
## Licensing
|
## Licensing
|
||||||
|
|
||||||
Pangolin is dual licensed under the AGPL-3 and the [Fossorial Commercial License](https://pangolin.net/fcl). For inquiries about commercial licensing, please contact us at [contact@pangolin.net](mailto:contact@pangolin.net).
|
Pangolin is dual licensed under the AGPL-3 and the [Fossorial Commercial License](https://pangolin.net/fcl.html). For inquiries about commercial licensing, please contact us at [contact@pangolin.net](mailto:contact@pangolin.net).
|
||||||
|
|
||||||
## Contributions
|
## Contributions
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { CommandModule } from "yargs";
|
import { CommandModule } from "yargs";
|
||||||
import { db, idpOidcConfig, licenseKey, certificates, eventStreamingDestinations, alertWebhookActions, aiProviders, virtualApiKeys } from "@server/db";
|
import { db, idpOidcConfig, licenseKey, certificates, eventStreamingDestinations, alertWebhookActions } from "@server/db";
|
||||||
import { encrypt, decrypt } from "@server/lib/crypto";
|
import { encrypt, decrypt } from "@server/lib/crypto";
|
||||||
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
@@ -132,16 +132,12 @@ export const rotateServerSecret: CommandModule<
|
|||||||
const certs = await db.select().from(certificates);
|
const certs = await db.select().from(certificates);
|
||||||
const streamingDestinations = await db.select().from(eventStreamingDestinations);
|
const streamingDestinations = await db.select().from(eventStreamingDestinations);
|
||||||
const webhookActions = await db.select().from(alertWebhookActions);
|
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 ${idpConfigs.length} OIDC IdP configuration(s)`);
|
||||||
console.log(`Found ${licenseKeys.length} license key(s)`);
|
console.log(`Found ${licenseKeys.length} license key(s)`);
|
||||||
console.log(`Found ${certs.length} certificate(s)`);
|
console.log(`Found ${certs.length} certificate(s)`);
|
||||||
console.log(`Found ${streamingDestinations.length} event streaming destination(s)`);
|
console.log(`Found ${streamingDestinations.length} event streaming destination(s)`);
|
||||||
console.log(`Found ${webhookActions.length} alert webhook action(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
|
// Prepare all decrypted and re-encrypted values
|
||||||
console.log("\nDecrypting and re-encrypting values...");
|
console.log("\nDecrypting and re-encrypting values...");
|
||||||
@@ -175,24 +171,11 @@ export const rotateServerSecret: CommandModule<
|
|||||||
encryptedConfig: string;
|
encryptedConfig: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AiProviderUpdate = {
|
|
||||||
providerId: number;
|
|
||||||
encryptedApiKey: string | null;
|
|
||||||
encryptedHeaders: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type VirtualApiKeyUpdate = {
|
|
||||||
virtualApiKeyId: string;
|
|
||||||
encryptedToken: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const idpUpdates: IdpUpdate[] = [];
|
const idpUpdates: IdpUpdate[] = [];
|
||||||
const licenseKeyUpdates: LicenseKeyUpdate[] = [];
|
const licenseKeyUpdates: LicenseKeyUpdate[] = [];
|
||||||
const certUpdates: CertUpdate[] = [];
|
const certUpdates: CertUpdate[] = [];
|
||||||
const streamingDestinationUpdates: StreamingDestinationUpdate[] = [];
|
const streamingDestinationUpdates: StreamingDestinationUpdate[] = [];
|
||||||
const webhookActionUpdates: WebhookActionUpdate[] = [];
|
const webhookActionUpdates: WebhookActionUpdate[] = [];
|
||||||
const aiProviderUpdates: AiProviderUpdate[] = [];
|
|
||||||
const virtualApiKeyUpdates: VirtualApiKeyUpdate[] = [];
|
|
||||||
|
|
||||||
// Process idpOidcConfig entries
|
// Process idpOidcConfig entries
|
||||||
for (const idpConfig of idpConfigs) {
|
for (const idpConfig of idpConfigs) {
|
||||||
@@ -323,60 +306,6 @@ 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
|
// Perform all database updates in a single transaction
|
||||||
console.log("\nUpdating database in transaction...");
|
console.log("\nUpdating database in transaction...");
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
@@ -447,32 +376,6 @@ 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)`);
|
console.log(`Rotated ${idpUpdates.length} OIDC IdP configuration(s)`);
|
||||||
@@ -480,8 +383,6 @@ export const rotateServerSecret: CommandModule<
|
|||||||
console.log(`Rotated ${certUpdates.length} certificate(s)`);
|
console.log(`Rotated ${certUpdates.length} certificate(s)`);
|
||||||
console.log(`Rotated ${streamingDestinationUpdates.length} event streaming destination(s)`);
|
console.log(`Rotated ${streamingDestinationUpdates.length} event streaming destination(s)`);
|
||||||
console.log(`Rotated ${webhookActionUpdates.length} alert webhook action(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
|
// Update config file with new secret
|
||||||
console.log("\nUpdating config file...");
|
console.log("\nUpdating config file...");
|
||||||
@@ -501,7 +402,6 @@ export const rotateServerSecret: CommandModule<
|
|||||||
console.log(` - Certificates: ${certUpdates.length}`);
|
console.log(` - Certificates: ${certUpdates.length}`);
|
||||||
console.log(` - Event streaming destinations: ${streamingDestinationUpdates.length}`);
|
console.log(` - Event streaming destinations: ${streamingDestinationUpdates.length}`);
|
||||||
console.log(` - Alert webhook actions: ${webhookActionUpdates.length}`);
|
console.log(` - Alert webhook actions: ${webhookActionUpdates.length}`);
|
||||||
console.log(` - AI providers: ${aiProviderUpdates.length}`);
|
|
||||||
console.log(
|
console.log(
|
||||||
`\n IMPORTANT: Restart the server for the new secret to take effect.`
|
`\n IMPORTANT: Restart the server for the new secret to take effect.`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
import { CommandModule } from "yargs";
|
|
||||||
import { db, users } from "@server/db";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
|
|
||||||
type SetServerAdminArgs = {
|
|
||||||
email: string;
|
|
||||||
remove: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const setServerAdmin: CommandModule<{}, SetServerAdminArgs> = {
|
|
||||||
command: "set-server-admin",
|
|
||||||
describe: "Add or remove server admin by email address",
|
|
||||||
builder: (yargs) => {
|
|
||||||
return yargs
|
|
||||||
.option("email", {
|
|
||||||
type: "string",
|
|
||||||
demandOption: true,
|
|
||||||
describe: "User email address"
|
|
||||||
})
|
|
||||||
.option("remove", {
|
|
||||||
type: "boolean",
|
|
||||||
default: false,
|
|
||||||
describe: "Remove server admin status from the user"
|
|
||||||
});
|
|
||||||
},
|
|
||||||
handler: async (argv: SetServerAdminArgs) => {
|
|
||||||
try {
|
|
||||||
const email = argv.email.trim().toLowerCase();
|
|
||||||
|
|
||||||
const [user] = await db
|
|
||||||
.select()
|
|
||||||
.from(users)
|
|
||||||
.where(eq(users.email, email))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
console.error(`User with email '${email}' not found`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (argv.remove) {
|
|
||||||
if (!user.serverAdmin) {
|
|
||||||
console.log(`User '${email}' is not a server admin`);
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const serverAdmins = await db
|
|
||||||
.select()
|
|
||||||
.from(users)
|
|
||||||
.where(eq(users.serverAdmin, true));
|
|
||||||
|
|
||||||
if (serverAdmins.length <= 1) {
|
|
||||||
console.error(
|
|
||||||
"Cannot remove server admin: at least one server admin must exist"
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(users)
|
|
||||||
.set({ serverAdmin: false })
|
|
||||||
.where(eq(users.userId, user.userId));
|
|
||||||
|
|
||||||
console.log(`Server admin status removed from user '${email}'`);
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.serverAdmin) {
|
|
||||||
console.log(`User '${email}' is already a server admin`);
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(users)
|
|
||||||
.set({ serverAdmin: true })
|
|
||||||
.where(eq(users.userId, user.userId));
|
|
||||||
|
|
||||||
console.log(`User '${email}' has been marked as a server admin`);
|
|
||||||
process.exit(0);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error:", error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -11,7 +11,6 @@ import { deleteClient } from "./commands/deleteClient";
|
|||||||
import { generateOrgCaKeys } from "./commands/generateOrgCaKeys";
|
import { generateOrgCaKeys } from "./commands/generateOrgCaKeys";
|
||||||
import { clearCertificates } from "./commands/clearCertificates";
|
import { clearCertificates } from "./commands/clearCertificates";
|
||||||
import { disableUser2fa } from "./commands/disableUser2fa";
|
import { disableUser2fa } from "./commands/disableUser2fa";
|
||||||
import { setServerAdmin } from "./commands/setServerAdmin";
|
|
||||||
|
|
||||||
yargs(hideBin(process.argv))
|
yargs(hideBin(process.argv))
|
||||||
.scriptName("pangctl")
|
.scriptName("pangctl")
|
||||||
@@ -24,6 +23,5 @@ yargs(hideBin(process.argv))
|
|||||||
.command(generateOrgCaKeys)
|
.command(generateOrgCaKeys)
|
||||||
.command(clearCertificates)
|
.command(clearCertificates)
|
||||||
.command(disableUser2fa)
|
.command(disableUser2fa)
|
||||||
.command(setServerAdmin)
|
|
||||||
.demandCommand()
|
.demandCommand()
|
||||||
.help().argv;
|
.help().argv;
|
||||||
|
|||||||
-7559
File diff suppressed because it is too large
Load Diff
@@ -1,47 +1,54 @@
|
|||||||
api:
|
api:
|
||||||
insecure: true
|
insecure: true
|
||||||
dashboard: true
|
dashboard: true
|
||||||
|
|
||||||
providers:
|
providers:
|
||||||
http:
|
http:
|
||||||
endpoint: http://pangolin:3001/api/v1/traefik-config
|
endpoint: "http://pangolin:3001/api/v1/traefik-config"
|
||||||
pollInterval: 5s
|
pollInterval: "5s"
|
||||||
file:
|
file:
|
||||||
filename: /etc/traefik/dynamic_config.yml
|
filename: "/etc/traefik/dynamic_config.yml"
|
||||||
|
|
||||||
experimental:
|
experimental:
|
||||||
plugins:
|
plugins:
|
||||||
badger:
|
badger:
|
||||||
moduleName: github.com/fosrl/badger
|
moduleName: "github.com/fosrl/badger"
|
||||||
version: v1.4.1
|
version: "{{.BadgerVersion}}"
|
||||||
|
|
||||||
log:
|
log:
|
||||||
level: INFO
|
level: "INFO"
|
||||||
format: common
|
format: "common"
|
||||||
maxSize: 100
|
maxSize: 100
|
||||||
maxBackups: 3
|
maxBackups: 3
|
||||||
maxAge: 3
|
maxAge: 3
|
||||||
compress: true
|
compress: true
|
||||||
|
|
||||||
certificatesResolvers:
|
certificatesResolvers:
|
||||||
letsencrypt:
|
letsencrypt:
|
||||||
acme:
|
acme:
|
||||||
httpChallenge:
|
httpChallenge:
|
||||||
entryPoint: web
|
entryPoint: web
|
||||||
email: '{{.LetsEncryptEmail}}'
|
email: "{{.LetsEncryptEmail}}"
|
||||||
storage: /letsencrypt/acme.json
|
storage: "/letsencrypt/acme.json"
|
||||||
caServer: https://acme-v02.api.letsencrypt.org/directory
|
caServer: "https://acme-v02.api.letsencrypt.org/directory"
|
||||||
|
|
||||||
entryPoints:
|
entryPoints:
|
||||||
web:
|
web:
|
||||||
address: ':80'
|
address: ":80"
|
||||||
websecure:
|
websecure:
|
||||||
address: ':443'
|
address: ":443"
|
||||||
transport:
|
transport:
|
||||||
respondingTimeouts:
|
respondingTimeouts:
|
||||||
readTimeout: 30m
|
readTimeout: "30m"
|
||||||
http:
|
http:
|
||||||
tls:
|
tls:
|
||||||
certResolver: letsencrypt
|
certResolver: "letsencrypt"
|
||||||
encodedCharacters:
|
encodedCharacters:
|
||||||
allowEncodedSlash: true
|
allowEncodedSlash: true
|
||||||
allowEncodedQuestionMark: true
|
allowEncodedQuestionMark: true
|
||||||
|
|
||||||
serversTransport:
|
serversTransport:
|
||||||
insecureSkipVerify: true
|
insecureSkipVerify: true
|
||||||
|
|
||||||
ping:
|
ping:
|
||||||
entryPoint: web
|
entryPoint: "web"
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ services:
|
|||||||
- 80:80 # Port for traefik because of the network_mode
|
- 80:80 # Port for traefik because of the network_mode
|
||||||
|
|
||||||
traefik:
|
traefik:
|
||||||
image: traefik:v3.7
|
image: traefik:v3.6
|
||||||
container_name: traefik
|
container_name: traefik
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
network_mode: service:gerbil # Ports appear on the gerbil service
|
network_mode: service:gerbil # Ports appear on the gerbil service
|
||||||
@@ -1,347 +0,0 @@
|
|||||||
# 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,4 +1,4 @@
|
|||||||
import { APP_PATH } from "./server/lib/consts";
|
import { APP_PATH } from "@server/lib/consts";
|
||||||
import { defineConfig } from "drizzle-kit";
|
import { defineConfig } from "drizzle-kit";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
|
|||||||
@@ -22,8 +22,7 @@ server:
|
|||||||
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"]
|
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"]
|
||||||
allowed_headers: ["X-CSRF-Token", "Content-Type"]
|
allowed_headers: ["X-CSRF-Token", "Content-Type"]
|
||||||
credentials: false
|
credentials: false
|
||||||
{{if .EnableMaxMind}}maxmind_db_path: "./config/GeoLite2-Country.mmdb"{{end}}
|
{{if .EnableGeoblocking}}maxmind_db_path: "./config/GeoLite2-Country.mmdb"{{end}}
|
||||||
{{if .EnableMaxMind}}maxmind_asn_path: "./config/GeoLite2-ASN.mmdb"{{end}}
|
|
||||||
{{if .EnableEmail}}
|
{{if .EnableEmail}}
|
||||||
email:
|
email:
|
||||||
smtp_host: "{{.EmailSMTPHost}}"
|
smtp_host: "{{.EmailSMTPHost}}"
|
||||||
@@ -37,6 +36,3 @@ flags:
|
|||||||
disable_signup_without_invite: true
|
disable_signup_without_invite: true
|
||||||
disable_user_create_org: false
|
disable_user_create_org: false
|
||||||
allow_raw_resources: true
|
allow_raw_resources: true
|
||||||
|
|
||||||
{{if .IsPostgreSQL}}postgres:
|
|
||||||
connection_string: postgresql://pangolin:{{.IsPostgreSQLPass}}@postgres:5432/pangolin{{end}}
|
|
||||||
|
|||||||
@@ -1,23 +1,15 @@
|
|||||||
name: pangolin
|
name: pangolin
|
||||||
services:
|
services:
|
||||||
pangolin:
|
pangolin:
|
||||||
image: docker.io/fosrl/pangolin:{{if .IsEnterprise}}ee-{{end}}{{if .IsPostgreSQL}}postgresql-{{end}}{{.PangolinVersion}}
|
image: docker.io/fosrl/pangolin:{{if .IsEnterprise}}ee-{{end}}{{.PangolinVersion}}
|
||||||
container_name: pangolin
|
container_name: pangolin
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
memory: 2g
|
memory: 1g
|
||||||
reservations:
|
reservations:
|
||||||
memory: 512m
|
memory: 256m
|
||||||
{{if or .IsPostgreSQL .IsRedis}}depends_on:
|
|
||||||
{{if .IsPostgreSQL}}postgres:
|
|
||||||
condition: service_healthy{{end}}
|
|
||||||
{{if .IsRedis}}redis:
|
|
||||||
condition: service_healthy{{end}}
|
|
||||||
networks:
|
|
||||||
- default
|
|
||||||
- backend{{end}}
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./config:/app/config
|
- ./config:/app/config
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -25,8 +17,8 @@ services:
|
|||||||
interval: "10s"
|
interval: "10s"
|
||||||
timeout: "10s"
|
timeout: "10s"
|
||||||
retries: 15
|
retries: 15
|
||||||
|
{{if .InstallGerbil}}
|
||||||
{{if .InstallGerbil}}gerbil:
|
gerbil:
|
||||||
image: docker.io/fosrl/gerbil:{{.GerbilVersion}}
|
image: docker.io/fosrl/gerbil:{{.GerbilVersion}}
|
||||||
container_name: gerbil
|
container_name: gerbil
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -47,16 +39,17 @@ services:
|
|||||||
- 21820:21820/udp
|
- 21820:21820/udp
|
||||||
- 443:443
|
- 443:443
|
||||||
- 443:443/udp # For http3 QUIC if desired
|
- 443:443/udp # For http3 QUIC if desired
|
||||||
- 80:80{{end}}
|
- 80:80
|
||||||
|
{{end}}
|
||||||
traefik:
|
traefik:
|
||||||
image: docker.io/traefik:v3.7
|
image: docker.io/traefik:v3.6
|
||||||
container_name: traefik
|
container_name: traefik
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
{{if .InstallGerbil}}network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}}
|
{{if .InstallGerbil}} network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}}
|
||||||
ports:
|
ports:
|
||||||
- 443:443
|
- 443:443
|
||||||
- 80:80{{end}}
|
- 80:80
|
||||||
|
{{end}}
|
||||||
depends_on:
|
depends_on:
|
||||||
pangolin:
|
pangolin:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -67,50 +60,8 @@ services:
|
|||||||
- ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
|
- ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
|
||||||
- ./config/traefik/logs:/var/log/traefik # Volume to store Traefik logs
|
- ./config/traefik/logs:/var/log/traefik # Volume to store Traefik logs
|
||||||
|
|
||||||
{{if .IsPostgreSQL}}postgres:
|
|
||||||
image: postgres:18
|
|
||||||
container_name: postgres
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: pangolin
|
|
||||||
POSTGRES_PASSWORD: {{.IsPostgreSQLPass}}
|
|
||||||
POSTGRES_DB: pangolin
|
|
||||||
volumes:
|
|
||||||
- ./postgres18:/var/lib/postgresql
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U pangolin"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
networks:
|
|
||||||
- backend{{end}}
|
|
||||||
|
|
||||||
{{if .IsRedis}}redis:
|
|
||||||
image: redis:8-trixie
|
|
||||||
container_name: redis
|
|
||||||
restart: unless-stopped
|
|
||||||
command: >
|
|
||||||
redis-server
|
|
||||||
--save 3600 1000
|
|
||||||
--appendonly yes
|
|
||||||
--requirepass {{.IsRedisPass}}
|
|
||||||
volumes:
|
|
||||||
- ./redis8:/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "redis-cli", "-a", "{{.IsRedisPass}}", "ping"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 3s
|
|
||||||
retries: 3
|
|
||||||
start_period: 10s
|
|
||||||
networks:
|
|
||||||
- backend{{end}}
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
name: pangolin_frontend
|
name: pangolin
|
||||||
{{if .EnableIPv6}} enable_ipv6: true{{end}}
|
{{if .EnableIPv6}} enable_ipv6: true{{end}}
|
||||||
{{if or .IsPostgreSQL .IsRedis}} backend:
|
|
||||||
driver: bridge
|
|
||||||
name: pangolin_backend
|
|
||||||
internal: true{{end}}
|
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
{{if .IsRedis}}redis:
|
|
||||||
host: "redis"
|
|
||||||
port: 6379
|
|
||||||
password: "{{.IsRedisPass}}"{{end}}
|
|
||||||
+2
-2
@@ -5,7 +5,7 @@ go 1.25.0
|
|||||||
require (
|
require (
|
||||||
github.com/charmbracelet/huh v1.0.0
|
github.com/charmbracelet/huh v1.0.0
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
golang.org/x/term v0.45.0
|
golang.org/x/term v0.42.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,6 +33,6 @@ require (
|
|||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
golang.org/x/sync v0.15.0 // indirect
|
golang.org/x/sync v0.15.0 // indirect
|
||||||
golang.org/x/sys v0.47.0 // indirect
|
golang.org/x/sys v0.43.0 // indirect
|
||||||
golang.org/x/text v0.23.0 // indirect
|
golang.org/x/text v0.23.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
+4
-4
@@ -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/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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
|||||||
+19
-52
@@ -54,13 +54,9 @@ type Config struct {
|
|||||||
InstallGerbil bool
|
InstallGerbil bool
|
||||||
TraefikBouncerKey string
|
TraefikBouncerKey string
|
||||||
DoCrowdsecInstall bool
|
DoCrowdsecInstall bool
|
||||||
EnableMaxMind bool
|
EnableGeoblocking bool
|
||||||
Secret string
|
Secret string
|
||||||
IsEnterprise bool
|
IsEnterprise bool
|
||||||
IsPostgreSQL bool
|
|
||||||
IsPostgreSQLPass string
|
|
||||||
IsRedis bool
|
|
||||||
IsRedisPass string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type SupportedContainer string
|
type SupportedContainer string
|
||||||
@@ -71,12 +67,9 @@ const (
|
|||||||
Undefined SupportedContainer = "undefined"
|
Undefined SupportedContainer = "undefined"
|
||||||
)
|
)
|
||||||
|
|
||||||
var redisFlag *bool
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
||||||
crowdsecFlag := flag.Bool("crowdsec", false, "Enable the CrowdSec installation prompt")
|
crowdsecFlag := flag.Bool("crowdsec", false, "Enable the CrowdSec installation prompt")
|
||||||
redisFlag = flag.Bool("redis", false, "Install Redis as caching solution. Required for HA. Not required for the Enterprise version.")
|
|
||||||
flag.Parse()
|
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
|
// 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
|
||||||
@@ -130,11 +123,11 @@ func main() {
|
|||||||
|
|
||||||
fmt.Println("\nConfiguration files created successfully!")
|
fmt.Println("\nConfiguration files created successfully!")
|
||||||
|
|
||||||
// Download MaxMind Country / ASN database if requested
|
// Download MaxMind database if requested
|
||||||
if config.EnableMaxMind {
|
if config.EnableGeoblocking {
|
||||||
fmt.Println("\n=== Downloading MaxMind Country and ASN Databases ===")
|
fmt.Println("\n=== Downloading MaxMind Database ===")
|
||||||
if err := downloadMaxMindDatabase(); err != nil {
|
if err := downloadMaxMindDatabase(); err != nil {
|
||||||
fmt.Printf("Error downloading MaxMind databases: %v\n", err)
|
fmt.Printf("Error downloading MaxMind database: %v\n", err)
|
||||||
fmt.Println("You can download it manually later if needed.")
|
fmt.Println("You can download it manually later if needed.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -195,15 +188,15 @@ func main() {
|
|||||||
fmt.Println("\n=== MaxMind Database Update ===")
|
fmt.Println("\n=== MaxMind Database Update ===")
|
||||||
if _, err := os.Stat("config/GeoLite2-Country.mmdb"); err == nil {
|
if _, err := os.Stat("config/GeoLite2-Country.mmdb"); err == nil {
|
||||||
fmt.Println("MaxMind GeoLite2 Country database found.")
|
fmt.Println("MaxMind GeoLite2 Country database found.")
|
||||||
if readBool("Would you like to update the MaxMind databases (Country and ASN) to the latest version?", false) {
|
if readBool("Would you like to update the MaxMind database to the latest version?", false) {
|
||||||
if err := downloadMaxMindDatabase(); err != nil {
|
if err := downloadMaxMindDatabase(); err != nil {
|
||||||
fmt.Printf("Error updating MaxMind database: %v\n", err)
|
fmt.Printf("Error updating MaxMind database: %v\n", err)
|
||||||
fmt.Println("You can try updating it manually later if needed.")
|
fmt.Println("You can try updating it manually later if needed.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("MaxMind GeoLite2 Country and ASN databases not found.")
|
fmt.Println("MaxMind GeoLite2 Country database not found.")
|
||||||
if readBool("Would you like to download the MaxMind GeoLite2 databases for blocking functionality?", false) {
|
if readBool("Would you like to download the MaxMind GeoLite2 database for geoblocking functionality?", false) {
|
||||||
if err := downloadMaxMindDatabase(); err != nil {
|
if err := downloadMaxMindDatabase(); err != nil {
|
||||||
fmt.Printf("Error downloading MaxMind database: %v\n", err)
|
fmt.Printf("Error downloading MaxMind database: %v\n", err)
|
||||||
fmt.Println("You can try downloading it manually later if needed.")
|
fmt.Println("You can try downloading it manually later if needed.")
|
||||||
@@ -211,10 +204,8 @@ func main() {
|
|||||||
// Now you need to update your config file accordingly to enable geoblocking
|
// Now you need to update your config file accordingly to enable geoblocking
|
||||||
fmt.Print("Please remember to update your config/config.yml file to enable geoblocking! \n\n")
|
fmt.Print("Please remember to update your config/config.yml file to enable geoblocking! \n\n")
|
||||||
// add maxmind_db_path: "./config/GeoLite2-Country.mmdb" under server
|
// add maxmind_db_path: "./config/GeoLite2-Country.mmdb" under server
|
||||||
// add maxmind_asn_path: "./config/GeoLite2-ASN.mmdb" under server
|
fmt.Println("Add the following line under the 'server' section:")
|
||||||
fmt.Println("Add the following lines under the 'server' section:")
|
|
||||||
fmt.Println(" maxmind_db_path: \"./config/GeoLite2-Country.mmdb\"")
|
fmt.Println(" maxmind_db_path: \"./config/GeoLite2-Country.mmdb\"")
|
||||||
fmt.Println(" maxmind_asn_path: \"./config/GeoLite2-ASN.mmdb\"")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -493,17 +484,6 @@ func collectUserInput() Config {
|
|||||||
fmt.Println("\n=== Basic Configuration ===")
|
fmt.Println("\n=== Basic Configuration ===")
|
||||||
|
|
||||||
config.IsEnterprise = readBoolNoDefault("Do you want to install the Enterprise version of Pangolin? The EE is free for personal use or for businesses making less than 100k USD annually.")
|
config.IsEnterprise = readBoolNoDefault("Do you want to install the Enterprise version of Pangolin? The EE is free for personal use or for businesses making less than 100k USD annually.")
|
||||||
if config.IsEnterprise {
|
|
||||||
if *redisFlag {
|
|
||||||
config.IsRedis = true
|
|
||||||
config.IsRedisPass = readPassword("Enter a unique password for the Redis service.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
config.IsPostgreSQL = readBool("Do you want to use PostgreSQL (not recommended for most users)?", false)
|
|
||||||
if config.IsPostgreSQL {
|
|
||||||
config.IsPostgreSQLPass = readPassword("Enter a unique password for the PostgreSQL pangolin user.")
|
|
||||||
}
|
|
||||||
|
|
||||||
config.BaseDomain = readString("Enter your base domain (no subdomain e.g. example.com)", "")
|
config.BaseDomain = readString("Enter your base domain (no subdomain e.g. example.com)", "")
|
||||||
|
|
||||||
@@ -547,7 +527,7 @@ func collectUserInput() Config {
|
|||||||
fmt.Println("\n=== Advanced Configuration ===")
|
fmt.Println("\n=== Advanced Configuration ===")
|
||||||
|
|
||||||
config.EnableIPv6 = readBool("Is your server IPv6 capable?", true)
|
config.EnableIPv6 = readBool("Is your server IPv6 capable?", true)
|
||||||
config.EnableMaxMind = readBool("Do you want to download the MaxMind GeoLite2 Country and ASN databases for blocking functionality?", true)
|
config.EnableGeoblocking = readBool("Do you want to download the MaxMind GeoLite2 database for geoblocking functionality?", true)
|
||||||
|
|
||||||
if config.DashboardDomain == "" {
|
if config.DashboardDomain == "" {
|
||||||
fmt.Println("Error: Dashboard Domain name is required")
|
fmt.Println("Error: Dashboard Domain name is required")
|
||||||
@@ -800,42 +780,29 @@ func checkPortsAvailable(port int) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func downloadMaxMindDatabase() error {
|
func downloadMaxMindDatabase() error {
|
||||||
fmt.Println("Downloading MaxMind GeoLite2 Country and ASN databases...")
|
fmt.Println("Downloading MaxMind GeoLite2 Country database...")
|
||||||
|
|
||||||
// Download the GeoLite2 Country databases
|
// Download the GeoLite2 Country database
|
||||||
if err := run("curl", "-L", "-o", "GeoLite2-Country.tar.gz",
|
if err := run("curl", "-L", "-o", "GeoLite2-Country.tar.gz",
|
||||||
"https://github.com/GitSquared/node-geolite2-redist/raw/refs/heads/master/redist/GeoLite2-Country.tar.gz"); err != nil {
|
"https://github.com/GitSquared/node-geolite2-redist/raw/refs/heads/master/redist/GeoLite2-Country.tar.gz"); err != nil {
|
||||||
return fmt.Errorf("failed to download GeoLite2 Country database: %v", err)
|
return fmt.Errorf("failed to download GeoLite2 database: %v", err)
|
||||||
}
|
|
||||||
if err := run("curl", "-L", "-o", "GeoLite2-ASN.tar.gz",
|
|
||||||
"https://github.com/GitSquared/node-geolite2-redist/raw/refs/heads/master/redist/GeoLite2-ASN.tar.gz"); err != nil {
|
|
||||||
return fmt.Errorf("failed to download GeoLite2 ASN database: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract the Country database
|
// Extract the database
|
||||||
if err := run("tar", "-xzf", "GeoLite2-Country.tar.gz"); err != nil {
|
if err := run("tar", "-xzf", "GeoLite2-Country.tar.gz"); err != nil {
|
||||||
return fmt.Errorf("failed to extract GeoLite2 Country database: %v", err)
|
return fmt.Errorf("failed to extract GeoLite2 database: %v", err)
|
||||||
}
|
|
||||||
if err := run("tar", "-xzf", "GeoLite2-ASN.tar.gz"); err != nil {
|
|
||||||
return fmt.Errorf("failed to extract GeoLite2 ASN database: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the .mmdb file and move it to the config directory
|
// Find the .mmdb file and move it to the config directory
|
||||||
if err := run("bash", "-c", "mv GeoLite2-Country_*/GeoLite2-Country.mmdb config/"); err != nil {
|
if err := run("bash", "-c", "mv GeoLite2-Country_*/GeoLite2-Country.mmdb config/"); err != nil {
|
||||||
return fmt.Errorf("failed to move GeoLite2 Country database to config directory: %v", err)
|
return fmt.Errorf("failed to move GeoLite2 database to config directory: %v", err)
|
||||||
}
|
|
||||||
if err := run("bash", "-c", "mv GeoLite2-ASN_*/GeoLite2-ASN.mmdb config/"); err != nil {
|
|
||||||
return fmt.Errorf("failed to move GeoLite2 ASN database to config directory: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up the downloaded files
|
// Clean up the downloaded files
|
||||||
if err := run("sh", "-c", "rm -rf GeoLite2-Country.tar.gz GeoLite2-Country_*"); err != nil {
|
if err := run("rm", "-rf", "GeoLite2-Country.tar.gz", "GeoLite2-Country_*"); err != nil {
|
||||||
fmt.Printf("Warning: failed to clean up temporary country files: %v\n", err)
|
fmt.Printf("Warning: failed to clean up temporary files: %v\n", err)
|
||||||
}
|
|
||||||
if err := run("sh", "-c", "rm -rf GeoLite2-ASN.tar.gz GeoLite2-ASN_*"); err != nil {
|
|
||||||
fmt.Printf("Warning: failed to clean up temporary ASN files: %v\n", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("MaxMind GeoLite2 Country and ASN database downloaded successfully!")
|
fmt.Println("MaxMind GeoLite2 Country database downloaded successfully!")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+59
-588
File diff suppressed because it is too large
Load Diff
+57
-586
File diff suppressed because it is too large
Load Diff
-3829
File diff suppressed because it is too large
Load Diff
+50
-579
File diff suppressed because it is too large
Load Diff
+59
-882
File diff suppressed because it is too large
Load Diff
+57
-586
File diff suppressed because it is too large
Load Diff
+49
-578
File diff suppressed because it is too large
Load Diff
+57
-586
File diff suppressed because it is too large
Load Diff
+53
-582
File diff suppressed because it is too large
Load Diff
+57
-586
File diff suppressed because it is too large
Load Diff
+91
-620
File diff suppressed because it is too large
Load Diff
+51
-580
File diff suppressed because it is too large
Load Diff
+58
-587
File diff suppressed because it is too large
Load Diff
+49
-578
File diff suppressed because it is too large
Load Diff
+58
-587
File diff suppressed because it is too large
Load Diff
+99
-628
File diff suppressed because it is too large
Load Diff
+2
-3
@@ -152,8 +152,8 @@
|
|||||||
"shareErrorSelectResource": "請選擇一個資源",
|
"shareErrorSelectResource": "請選擇一個資源",
|
||||||
"proxyResourceTitle": "管理公開資源",
|
"proxyResourceTitle": "管理公開資源",
|
||||||
"proxyResourceDescription": "建立和管理可透過網頁瀏覽器公開存取的資源",
|
"proxyResourceDescription": "建立和管理可透過網頁瀏覽器公開存取的資源",
|
||||||
"publicResourcesBannerTitle": "基於網頁的公開存取",
|
"proxyResourcesBannerTitle": "基於網頁的公開存取",
|
||||||
"publicResourcesBannerDescription": "公開資源是任何人都可以透過網頁瀏覽器存取的 HTTPS 或 TCP/UDP 代理。與私有資源不同,它們不需要客戶端軟體,並且可以包含基於身份和情境感知的存取策略。",
|
"proxyResourcesBannerDescription": "公開資源是任何人都可以透過網頁瀏覽器存取的 HTTPS 或 TCP/UDP 代理。與私有資源不同,它們不需要客戶端軟體,並且可以包含基於身份和情境感知的存取策略。",
|
||||||
"clientResourceTitle": "管理私有資源",
|
"clientResourceTitle": "管理私有資源",
|
||||||
"clientResourceDescription": "建立和管理只能透過已連接的客戶端存取的資源",
|
"clientResourceDescription": "建立和管理只能透過已連接的客戶端存取的資源",
|
||||||
"privateResourcesBannerTitle": "零信任私有存取",
|
"privateResourcesBannerTitle": "零信任私有存取",
|
||||||
@@ -1099,7 +1099,6 @@
|
|||||||
"actionGenerateAccessToken": "生成訪問令牌",
|
"actionGenerateAccessToken": "生成訪問令牌",
|
||||||
"actionDeleteAccessToken": "刪除訪問令牌",
|
"actionDeleteAccessToken": "刪除訪問令牌",
|
||||||
"actionListAccessTokens": "訪問令牌",
|
"actionListAccessTokens": "訪問令牌",
|
||||||
"actionCreateResourceSessionToken": "建立資源工作階段權杖",
|
|
||||||
"actionCreateResourceRule": "創建資源規則",
|
"actionCreateResourceRule": "創建資源規則",
|
||||||
"actionDeleteResourceRule": "刪除資源規則",
|
"actionDeleteResourceRule": "刪除資源規則",
|
||||||
"actionListResourceRules": "列出資源規則",
|
"actionListResourceRules": "列出資源規則",
|
||||||
|
|||||||
+7
-32
@@ -1,42 +1,17 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
import createNextIntlPlugin from "next-intl/plugin";
|
import createNextIntlPlugin from "next-intl/plugin";
|
||||||
import fs from "fs";
|
|
||||||
import path from "path";
|
|
||||||
|
|
||||||
const withNextIntl = createNextIntlPlugin();
|
const withNextIntl = createNextIntlPlugin();
|
||||||
// read allowedDevOrigins.json if it exists
|
|
||||||
let allowedDevOrigins: string[] = [];
|
|
||||||
const allowedDevOriginsPath = path.join(
|
|
||||||
process.cwd(),
|
|
||||||
"allowedDevOrigins.json"
|
|
||||||
);
|
|
||||||
if (fs.existsSync(allowedDevOriginsPath)) {
|
|
||||||
try {
|
|
||||||
const data = fs.readFileSync(allowedDevOriginsPath, "utf-8");
|
|
||||||
allowedDevOrigins = JSON.parse(data);
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
reactStrictMode: false,
|
reactStrictMode: false,
|
||||||
reactCompiler: true,
|
eslint: {
|
||||||
transpilePackages: ["@novnc/novnc"],
|
ignoreDuringBuilds: true
|
||||||
output: "standalone",
|
},
|
||||||
allowedDevOrigins,
|
experimental: {
|
||||||
async redirects() {
|
reactCompiler: true
|
||||||
return [
|
},
|
||||||
{
|
output: "standalone"
|
||||||
source: "/:orgId/settings/resources/proxy/:path*",
|
|
||||||
destination: "/:orgId/settings/resources/public/:path*",
|
|
||||||
permanent: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
source: "/:orgId/settings/resources/client/:path*",
|
|
||||||
destination: "/:orgId/settings/resources/private/:path*",
|
|
||||||
permanent: true
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withNextIntl(nextConfig);
|
export default withNextIntl(nextConfig);
|
||||||
|
|||||||
Generated
+3522
-2462
File diff suppressed because it is too large
Load Diff
+49
-56
@@ -32,15 +32,13 @@
|
|||||||
"format": "prettier --write ."
|
"format": "prettier --write ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@asteasolutions/zod-to-openapi": "8.5.0",
|
"@asteasolutions/zod-to-openapi": "8.4.1",
|
||||||
"@aws-sdk/client-s3": "3.1056.0",
|
"@aws-sdk/client-s3": "3.1011.0",
|
||||||
"@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
|
"@faker-js/faker": "10.3.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",
|
"@headlessui/react": "2.2.10",
|
||||||
"@hookform/resolvers": "5.4.0",
|
"@hookform/resolvers": "5.2.2",
|
||||||
"@monaco-editor/react": "4.7.0",
|
"@monaco-editor/react": "4.7.0",
|
||||||
"@node-rs/argon2": "2.0.2",
|
"@node-rs/argon2": "2.0.2",
|
||||||
"@novnc/novnc": "^1.7.0",
|
|
||||||
"@oslojs/crypto": "1.0.1",
|
"@oslojs/crypto": "1.0.1",
|
||||||
"@oslojs/encoding": "1.1.0",
|
"@oslojs/encoding": "1.1.0",
|
||||||
"@radix-ui/react-avatar": "1.1.11",
|
"@radix-ui/react-avatar": "1.1.11",
|
||||||
@@ -61,20 +59,16 @@
|
|||||||
"@radix-ui/react-tabs": "1.1.13",
|
"@radix-ui/react-tabs": "1.1.13",
|
||||||
"@radix-ui/react-toast": "1.2.15",
|
"@radix-ui/react-toast": "1.2.15",
|
||||||
"@radix-ui/react-tooltip": "1.2.8",
|
"@radix-ui/react-tooltip": "1.2.8",
|
||||||
"@react-email/body": "0.3.0",
|
|
||||||
"@react-email/components": "1.0.12",
|
"@react-email/components": "1.0.12",
|
||||||
"@react-email/render": "2.0.8",
|
"@react-email/render": "2.0.8",
|
||||||
"@react-email/tailwind": "2.0.7",
|
"@react-email/tailwind": "2.0.7",
|
||||||
"@simplewebauthn/browser": "13.3.0",
|
"@simplewebauthn/browser": "13.3.0",
|
||||||
"@simplewebauthn/server": "13.3.1",
|
"@simplewebauthn/server": "13.3.0",
|
||||||
"@tailwindcss/forms": "0.5.11",
|
"@tailwindcss/forms": "0.5.11",
|
||||||
"@tanstack/react-query": "5.100.14",
|
"@tanstack/react-query": "5.90.21",
|
||||||
"@tanstack/react-table": "8.21.3",
|
"@tanstack/react-table": "8.21.3",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
|
||||||
"@xterm/addon-web-links": "^0.12.0",
|
|
||||||
"@xterm/xterm": "^6.0.0",
|
|
||||||
"arctic": "3.7.0",
|
"arctic": "3.7.0",
|
||||||
"axios": "1.18.0",
|
"axios": "1.15.0",
|
||||||
"better-sqlite3": "11.9.1",
|
"better-sqlite3": "11.9.1",
|
||||||
"canvas-confetti": "1.9.4",
|
"canvas-confetti": "1.9.4",
|
||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "0.7.1",
|
||||||
@@ -86,77 +80,77 @@
|
|||||||
"d3": "7.9.0",
|
"d3": "7.9.0",
|
||||||
"drizzle-orm": "0.45.2",
|
"drizzle-orm": "0.45.2",
|
||||||
"express": "5.2.1",
|
"express": "5.2.1",
|
||||||
"express-rate-limit": "8.5.2",
|
"express-rate-limit": "8.3.0",
|
||||||
"glob": "13.0.6",
|
"glob": "13.0.6",
|
||||||
"gpt-tokenizer": "^3.4.0",
|
"helmet": "8.1.0",
|
||||||
"helmet": "8.2.0",
|
|
||||||
"http-errors": "2.0.1",
|
"http-errors": "2.0.1",
|
||||||
"input-otp": "1.4.2",
|
"input-otp": "1.4.2",
|
||||||
"ioredis": "5.11.0",
|
"ioredis": "5.10.1",
|
||||||
"jmespath": "0.16.0",
|
"jmespath": "0.16.0",
|
||||||
"js-yaml": "4.3.0",
|
"js-yaml": "4.1.1",
|
||||||
"jsonwebtoken": "9.0.3",
|
"jsonwebtoken": "9.0.3",
|
||||||
"lucide-react": "1.17.0",
|
"lucide-react": "0.577.0",
|
||||||
"maxmind": "5.0.6",
|
"maxmind": "5.0.6",
|
||||||
"moment": "2.30.1",
|
"moment": "2.30.1",
|
||||||
"next": "16.2.11",
|
"next": "15.5.15",
|
||||||
"next-intl": "4.13.0",
|
"next-intl": "4.8.3",
|
||||||
"next-themes": "0.4.6",
|
"next-themes": "0.4.6",
|
||||||
"nextjs-toploader": "3.9.17",
|
"nextjs-toploader": "3.9.17",
|
||||||
"node-cache": "5.1.2",
|
"node-cache": "5.1.2",
|
||||||
"nodemailer": "9.0.1",
|
"nodemailer": "8.0.7",
|
||||||
"oslo": "1.2.1",
|
"oslo": "1.2.1",
|
||||||
"pg": "8.21.0",
|
"pg": "8.20.0",
|
||||||
"posthog-node": "5.35.6",
|
"posthog-node": "5.28.0",
|
||||||
"qrcode.react": "4.2.0",
|
"qrcode.react": "4.2.0",
|
||||||
"react": "19.2.6",
|
"react": "19.2.6",
|
||||||
"react-day-picker": "9.14.0",
|
"react-day-picker": "9.14.0",
|
||||||
"react-dom": "19.2.6",
|
"react-dom": "19.2.6",
|
||||||
"react-easy-sort": "1.8.0",
|
"react-easy-sort": "1.8.0",
|
||||||
"react-hook-form": "7.76.1",
|
"react-hook-form": "7.71.2",
|
||||||
"react-icons": "5.6.0",
|
"react-icons": "5.6.0",
|
||||||
"recharts": "3.8.1",
|
"recharts": "2.15.4",
|
||||||
"reodotdev": "1.1.0",
|
"reodotdev": "1.1.0",
|
||||||
"semver": "7.8.1",
|
"resend": "6.9.2",
|
||||||
|
"semver": "7.7.4",
|
||||||
"sshpk": "1.18.0",
|
"sshpk": "1.18.0",
|
||||||
"stripe": "22.2.0",
|
"stripe": "20.4.1",
|
||||||
"swagger-ui-express": "5.0.1",
|
"swagger-ui-express": "5.0.1",
|
||||||
"tailwind-merge": "3.6.0",
|
"tailwind-merge": "3.5.0",
|
||||||
"topojson-client": "3.1.0",
|
"topojson-client": "3.1.0",
|
||||||
"tw-animate-css": "1.4.0",
|
"tw-animate-css": "1.4.0",
|
||||||
"use-debounce": "10.1.1",
|
"use-debounce": "10.1.1",
|
||||||
"uuid": "14.0.0",
|
"uuid": "13.0.0",
|
||||||
"vaul": "1.1.2",
|
"vaul": "1.1.2",
|
||||||
"visionscarto-world-atlas": "1.0.0",
|
"visionscarto-world-atlas": "1.0.0",
|
||||||
"winston": "3.19.0",
|
"winston": "3.19.0",
|
||||||
"winston-daily-rotate-file": "5.0.0",
|
"winston-daily-rotate-file": "5.0.0",
|
||||||
"ws": "8.21.0",
|
"ws": "8.19.0",
|
||||||
"yaml": "2.9.0",
|
"yaml": "2.8.3",
|
||||||
"yargs": "18.0.0",
|
"yargs": "18.0.0",
|
||||||
"zod": "4.4.3",
|
"zod": "4.3.6",
|
||||||
"zod-validation-error": "5.0.0"
|
"zod-validation-error": "5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@dotenvx/dotenvx": "1.69.1",
|
"@dotenvx/dotenvx": "1.54.1",
|
||||||
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
||||||
"@react-email/ui": "^6.5.0",
|
"@react-email/preview-server": "5.2.10",
|
||||||
"@tailwindcss/postcss": "4.3.0",
|
"@tailwindcss/postcss": "4.2.2",
|
||||||
"@tanstack/react-query-devtools": "5.100.14",
|
"@tanstack/react-query-devtools": "5.91.3",
|
||||||
"@types/better-sqlite3": "7.6.13",
|
"@types/better-sqlite3": "7.6.13",
|
||||||
"@types/cookie-parser": "1.4.10",
|
"@types/cookie-parser": "1.4.10",
|
||||||
"@types/cors": "2.8.19",
|
"@types/cors": "2.8.19",
|
||||||
"@types/crypto-js": "4.2.2",
|
"@types/crypto-js": "4.2.2",
|
||||||
"@types/d3": "7.4.3",
|
"@types/d3": "7.4.3",
|
||||||
"@types/express": "5.0.6",
|
"@types/express": "5.0.6",
|
||||||
"@types/express-session": "1.19.0",
|
"@types/express-session": "1.18.2",
|
||||||
"@types/jmespath": "0.15.2",
|
"@types/jmespath": "0.15.2",
|
||||||
"@types/js-yaml": "4.0.9",
|
"@types/js-yaml": "4.0.9",
|
||||||
"@types/jsonwebtoken": "9.0.10",
|
"@types/jsonwebtoken": "9.0.10",
|
||||||
"@types/node": "25.9.1",
|
"@types/node": "25.3.5",
|
||||||
"@types/nodemailer": "8.0.0",
|
"@types/nodemailer": "8.0.0",
|
||||||
"@types/nprogress": "0.2.3",
|
"@types/nprogress": "0.2.3",
|
||||||
"@types/pg": "8.20.0",
|
"@types/pg": "8.18.0",
|
||||||
"@types/react": "19.2.15",
|
"@types/react": "19.2.14",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"@types/semver": "7.7.1",
|
"@types/semver": "7.7.1",
|
||||||
"@types/sshpk": "1.17.4",
|
"@types/sshpk": "1.17.4",
|
||||||
@@ -166,22 +160,21 @@
|
|||||||
"@types/yargs": "17.0.35",
|
"@types/yargs": "17.0.35",
|
||||||
"babel-plugin-react-compiler": "1.0.0",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"drizzle-kit": "0.31.10",
|
"drizzle-kit": "0.31.10",
|
||||||
"esbuild": "0.28.1",
|
"esbuild": "0.27.4",
|
||||||
"esbuild-node-externals": "1.22.0",
|
"esbuild-node-externals": "1.20.1",
|
||||||
"eslint": "10.4.0",
|
"eslint": "10.0.3",
|
||||||
"eslint-config-next": "16.2.6",
|
"eslint-config-next": "16.1.7",
|
||||||
"postcss": "8.5.15",
|
"postcss": "8.5.8",
|
||||||
"prettier": "3.8.3",
|
"prettier": "3.8.1",
|
||||||
"react-email": "6.5.0",
|
"react-email": "5.2.10",
|
||||||
"tailwindcss": "4.3.0",
|
"tailwindcss": "4.2.2",
|
||||||
"tsc-alias": "1.8.17",
|
"tsc-alias": "1.8.16",
|
||||||
"tsx": "4.22.3",
|
"tsx": "4.21.0",
|
||||||
"typescript": "6.0.3",
|
"typescript": "5.9.3",
|
||||||
"typescript-eslint": "8.60.0"
|
"typescript-eslint": "8.56.1"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"esbuild": "0.28.1",
|
"esbuild": "0.27.4",
|
||||||
"dompurify": "3.4.0",
|
"dompurify": "3.3.2"
|
||||||
"postcss": "8.5.15"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 556 KiB |
@@ -1,39 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
+15
-71
@@ -5,7 +5,6 @@ import { and, eq, inArray } from "drizzle-orm";
|
|||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||||
import logger from "@server/logger";
|
|
||||||
|
|
||||||
export enum ActionsEnum {
|
export enum ActionsEnum {
|
||||||
createOrgUser = "createOrgUser",
|
createOrgUser = "createOrgUser",
|
||||||
@@ -21,8 +20,6 @@ export enum ActionsEnum {
|
|||||||
getSite = "getSite",
|
getSite = "getSite",
|
||||||
listSites = "listSites",
|
listSites = "listSites",
|
||||||
updateSite = "updateSite",
|
updateSite = "updateSite",
|
||||||
updateSiteApprovals = "updateSiteApprovals",
|
|
||||||
restartSite = "restartSite",
|
|
||||||
resetSiteBandwidth = "resetSiteBandwidth",
|
resetSiteBandwidth = "resetSiteBandwidth",
|
||||||
reGenerateSecret = "reGenerateSecret",
|
reGenerateSecret = "reGenerateSecret",
|
||||||
createResource = "createResource",
|
createResource = "createResource",
|
||||||
@@ -50,8 +47,6 @@ export enum ActionsEnum {
|
|||||||
setResourceUsers = "setResourceUsers",
|
setResourceUsers = "setResourceUsers",
|
||||||
setResourceRoles = "setResourceRoles",
|
setResourceRoles = "setResourceRoles",
|
||||||
listResourceUsers = "listResourceUsers",
|
listResourceUsers = "listResourceUsers",
|
||||||
listResourceAiModels = "listResourceAiModels",
|
|
||||||
setResourceAiModels = "setResourceAiModels",
|
|
||||||
// removeRoleSite = "removeRoleSite",
|
// removeRoleSite = "removeRoleSite",
|
||||||
// addRoleAction = "addRoleAction",
|
// addRoleAction = "addRoleAction",
|
||||||
// removeRoleAction = "removeRoleAction",
|
// removeRoleAction = "removeRoleAction",
|
||||||
@@ -74,7 +69,6 @@ export enum ActionsEnum {
|
|||||||
setResourceWhitelist = "setResourceWhitelist",
|
setResourceWhitelist = "setResourceWhitelist",
|
||||||
getResourceWhitelist = "getResourceWhitelist",
|
getResourceWhitelist = "getResourceWhitelist",
|
||||||
generateAccessToken = "generateAccessToken",
|
generateAccessToken = "generateAccessToken",
|
||||||
createResourceSessionToken = "createResourceSessionToken",
|
|
||||||
deleteAcessToken = "deleteAcessToken",
|
deleteAcessToken = "deleteAcessToken",
|
||||||
listAccessTokens = "listAccessTokens",
|
listAccessTokens = "listAccessTokens",
|
||||||
createResourceRule = "createResourceRule",
|
createResourceRule = "createResourceRule",
|
||||||
@@ -154,57 +148,11 @@ export enum ActionsEnum {
|
|||||||
updateAlertRule = "updateAlertRule",
|
updateAlertRule = "updateAlertRule",
|
||||||
deleteAlertRule = "deleteAlertRule",
|
deleteAlertRule = "deleteAlertRule",
|
||||||
listAlertRules = "listAlertRules",
|
listAlertRules = "listAlertRules",
|
||||||
listOrgLabels = "listOrgLabels",
|
|
||||||
createOrgLabel = "createOrgLabel",
|
|
||||||
updateOrgLabel = "updateOrgLabel",
|
|
||||||
deleteOrgLabel = "deleteOrgLabel",
|
|
||||||
attachLabelToItem = "attachLabelToItem",
|
|
||||||
detachLabelFromItem = "detachLabelFromItem",
|
|
||||||
getAlertRule = "getAlertRule",
|
getAlertRule = "getAlertRule",
|
||||||
createHealthCheck = "createHealthCheck",
|
createHealthCheck = "createHealthCheck",
|
||||||
updateHealthCheck = "updateHealthCheck",
|
updateHealthCheck = "updateHealthCheck",
|
||||||
deleteHealthCheck = "deleteHealthCheck",
|
deleteHealthCheck = "deleteHealthCheck",
|
||||||
listHealthChecks = "listHealthChecks",
|
listHealthChecks = "listHealthChecks"
|
||||||
createBrowserGatewayTarget = "createBrowserGatewayTarget",
|
|
||||||
updateBrowserGatewayTarget = "updateBrowserGatewayTarget",
|
|
||||||
deleteBrowserGatewayTarget = "deleteBrowserGatewayTarget",
|
|
||||||
getBrowserGatewayTarget = "getBrowserGatewayTarget",
|
|
||||||
listBrowserGatewayTargets = "listBrowserGatewayTargets",
|
|
||||||
listResourcePolicies = "listResourcePolicies",
|
|
||||||
getResourcePolicy = "getResourcePolicy",
|
|
||||||
createResourcePolicy = "createResourcePolicy",
|
|
||||||
updateResourcePolicy = "updateResourcePolicy",
|
|
||||||
deleteResourcePolicy = "deleteResourcePolicy",
|
|
||||||
listResourcePolicyRoles = "listResourcePolicyRoles",
|
|
||||||
setResourcePolicyRoles = "setResourcePolicyRoles",
|
|
||||||
listResourcePolicyUsers = "listResourcePolicyUsers",
|
|
||||||
setResourcePolicyUsers = "setResourcePolicyUsers",
|
|
||||||
setResourcePolicyPassword = "setResourcePolicyPassword",
|
|
||||||
setResourcePolicyPincode = "setResourcePolicyPincode",
|
|
||||||
setResourcePolicyHeaderAuth = "setResourcePolicyHeaderAuth",
|
|
||||||
setResourcePolicyWhitelist = "setResourcePolicyWhitelist",
|
|
||||||
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(
|
export async function checkUserActionPermission(
|
||||||
@@ -237,23 +185,6 @@ export async function checkUserActionPermission(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no direct permission, check role-based permission (any of user's roles)
|
|
||||||
const roleActionPermission = await db
|
|
||||||
.select()
|
|
||||||
.from(roleActions)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(roleActions.actionId, actionId),
|
|
||||||
inArray(roleActions.roleId, userOrgRoleIds),
|
|
||||||
eq(roleActions.orgId, req.userOrgId!)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (roleActionPermission.length > 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the user has direct permission for the action in the current org
|
// Check if the user has direct permission for the action in the current org
|
||||||
const userActionPermission = await db
|
const userActionPermission = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -271,7 +202,20 @@ export async function checkUserActionPermission(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
// If no direct permission, check role-based permission (any of user's roles)
|
||||||
|
const roleActionPermission = await db
|
||||||
|
.select()
|
||||||
|
.from(roleActions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(roleActions.actionId, actionId),
|
||||||
|
inArray(roleActions.roleId, userOrgRoleIds),
|
||||||
|
eq(roleActions.orgId, req.userOrgId!)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return roleActionPermission.length > 0;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error checking user action permission:", error);
|
console.error("Error checking user action permission:", error);
|
||||||
throw createHttpError(
|
throw createHttpError(
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { and, eq, inArray, isNull, or } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
import {
|
import { roleResources, userResources } from "@server/db";
|
||||||
rolePolicies,
|
|
||||||
roleResources,
|
|
||||||
resources,
|
|
||||||
userPolicies,
|
|
||||||
userResources
|
|
||||||
} from "@server/db";
|
|
||||||
|
|
||||||
export async function canUserAccessResource({
|
export async function canUserAccessResource({
|
||||||
userId,
|
userId,
|
||||||
@@ -17,14 +11,9 @@ export async function canUserAccessResource({
|
|||||||
resourceId: number;
|
resourceId: number;
|
||||||
roleIds: number[];
|
roleIds: number[];
|
||||||
}): Promise<boolean> {
|
}): Promise<boolean> {
|
||||||
const [
|
const roleResourceAccess =
|
||||||
roleResourceAccess,
|
|
||||||
rolePolicyAccess,
|
|
||||||
userResourceAccess,
|
|
||||||
userPolicyAccess
|
|
||||||
] = await Promise.all([
|
|
||||||
roleIds.length > 0
|
roleIds.length > 0
|
||||||
? db
|
? await db
|
||||||
.select()
|
.select()
|
||||||
.from(roleResources)
|
.from(roleResources)
|
||||||
.where(
|
.where(
|
||||||
@@ -34,87 +23,26 @@ export async function canUserAccessResource({
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
: [],
|
: [];
|
||||||
roleIds.length > 0
|
|
||||||
? db
|
|
||||||
.select({
|
|
||||||
roleId: rolePolicies.roleId,
|
|
||||||
resourcePolicyId: rolePolicies.resourcePolicyId
|
|
||||||
})
|
|
||||||
.from(rolePolicies)
|
|
||||||
.innerJoin(
|
|
||||||
resources,
|
|
||||||
// Shared policy wins; only use default policy when no shared
|
|
||||||
// policy is assigned to the resource.
|
|
||||||
or(
|
|
||||||
eq(
|
|
||||||
resources.resourcePolicyId,
|
|
||||||
rolePolicies.resourcePolicyId
|
|
||||||
),
|
|
||||||
and(
|
|
||||||
isNull(resources.resourcePolicyId),
|
|
||||||
eq(
|
|
||||||
resources.defaultResourcePolicyId,
|
|
||||||
rolePolicies.resourcePolicyId
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(resources.resourceId, resourceId),
|
|
||||||
inArray(rolePolicies.roleId, roleIds)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
: [],
|
|
||||||
db
|
|
||||||
.select()
|
|
||||||
.from(userResources)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(userResources.userId, userId),
|
|
||||||
eq(userResources.resourceId, resourceId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1),
|
|
||||||
db
|
|
||||||
.select({
|
|
||||||
userId: userPolicies.userId,
|
|
||||||
resourcePolicyId: userPolicies.resourcePolicyId
|
|
||||||
})
|
|
||||||
.from(userPolicies)
|
|
||||||
.innerJoin(
|
|
||||||
resources,
|
|
||||||
// Shared policy wins; only use default policy when no shared
|
|
||||||
// policy is assigned to the resource.
|
|
||||||
or(
|
|
||||||
eq(
|
|
||||||
resources.resourcePolicyId,
|
|
||||||
userPolicies.resourcePolicyId
|
|
||||||
),
|
|
||||||
and(
|
|
||||||
isNull(resources.resourcePolicyId),
|
|
||||||
eq(
|
|
||||||
resources.defaultResourcePolicyId,
|
|
||||||
userPolicies.resourcePolicyId
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(resources.resourceId, resourceId),
|
|
||||||
eq(userPolicies.userId, userId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
if (roleResourceAccess.length > 0) {
|
||||||
roleResourceAccess.length > 0 ||
|
return true;
|
||||||
rolePolicyAccess.length > 0 ||
|
}
|
||||||
userResourceAccess.length > 0 ||
|
|
||||||
userPolicyAccess.length > 0
|
const userResourceAccess = await db
|
||||||
);
|
.select()
|
||||||
|
.from(userResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userResources.userId, userId),
|
||||||
|
eq(userResources.resourceId, resourceId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (userResourceAccess.length > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
users
|
users
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { and, eq, inArray, ne } from "drizzle-orm";
|
import { eq, inArray } from "drizzle-orm";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import type { RandomReader } from "@oslojs/crypto/random";
|
import type { RandomReader } from "@oslojs/crypto/random";
|
||||||
import { generateRandomString } from "@oslojs/crypto/random";
|
import { generateRandomString } from "@oslojs/crypto/random";
|
||||||
@@ -136,45 +136,6 @@ export async function invalidateAllSessions(userId: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function invalidateAllSessionsExceptCurrent(
|
|
||||||
userId: string,
|
|
||||||
currentSessionId: string
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
await db.transaction(async (trx) => {
|
|
||||||
const userSessions = await trx
|
|
||||||
.select()
|
|
||||||
.from(sessions)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(sessions.userId, userId),
|
|
||||||
ne(sessions.sessionId, currentSessionId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (userSessions.length > 0) {
|
|
||||||
await trx.delete(resourceSessions).where(
|
|
||||||
inArray(
|
|
||||||
resourceSessions.userSessionId,
|
|
||||||
userSessions.map((s) => s.sessionId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await trx
|
|
||||||
.delete(sessions)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(sessions.userId, userId),
|
|
||||||
ne(sessions.sessionId, currentSessionId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
logger.error("Failed to invalidate user sessions except current", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function serializeSessionCookie(
|
export function serializeSessionCookie(
|
||||||
token: string,
|
token: string,
|
||||||
isSecure: boolean,
|
isSecure: boolean,
|
||||||
|
|||||||
@@ -19,9 +19,6 @@ export async function createResourceSession(opts: {
|
|||||||
userSessionId?: string | null;
|
userSessionId?: string | null;
|
||||||
whitelistId?: number | null;
|
whitelistId?: number | null;
|
||||||
accessTokenId?: string | null;
|
accessTokenId?: string | null;
|
||||||
policyPasswordId?: number | null;
|
|
||||||
policyPincodeId?: number | null;
|
|
||||||
policyWhitelistId?: number | null;
|
|
||||||
doNotExtend?: boolean;
|
doNotExtend?: boolean;
|
||||||
expiresAt?: number | null;
|
expiresAt?: number | null;
|
||||||
sessionLength?: number | null;
|
sessionLength?: number | null;
|
||||||
@@ -31,10 +28,7 @@ export async function createResourceSession(opts: {
|
|||||||
!opts.pincodeId &&
|
!opts.pincodeId &&
|
||||||
!opts.whitelistId &&
|
!opts.whitelistId &&
|
||||||
!opts.accessTokenId &&
|
!opts.accessTokenId &&
|
||||||
!opts.userSessionId &&
|
!opts.userSessionId
|
||||||
!opts.policyPasswordId &&
|
|
||||||
!opts.policyPincodeId &&
|
|
||||||
!opts.policyWhitelistId
|
|
||||||
) {
|
) {
|
||||||
throw new Error("Auth method must be provided");
|
throw new Error("Auth method must be provided");
|
||||||
}
|
}
|
||||||
@@ -55,9 +49,6 @@ export async function createResourceSession(opts: {
|
|||||||
whitelistId: opts.whitelistId || null,
|
whitelistId: opts.whitelistId || null,
|
||||||
doNotExtend: opts.doNotExtend || false,
|
doNotExtend: opts.doNotExtend || false,
|
||||||
accessTokenId: opts.accessTokenId || null,
|
accessTokenId: opts.accessTokenId || null,
|
||||||
policyPasswordId: opts.policyPasswordId || null,
|
|
||||||
policyPincodeId: opts.policyPincodeId || null,
|
|
||||||
policyWhitelistId: opts.policyWhitelistId || null,
|
|
||||||
isRequestToken: opts.isRequestToken || false,
|
isRequestToken: opts.isRequestToken || false,
|
||||||
userSessionId: opts.userSessionId || null,
|
userSessionId: opts.userSessionId || null,
|
||||||
issuedAt: new Date().getTime()
|
issuedAt: new Date().getTime()
|
||||||
|
|||||||
@@ -795,13 +795,10 @@ export const COUNTRIES = [
|
|||||||
name: "Serbia",
|
name: "Serbia",
|
||||||
code: "RS"
|
code: "RS"
|
||||||
},
|
},
|
||||||
// Removed as this is a deprecated ISO country code, not supported anymore
|
{
|
||||||
// Also the individual flags for Serbia & Montenegro are already included in the list
|
name: "Serbia and Montenegro",
|
||||||
// more details: https://en.wikipedia.org/wiki/ISO_3166-2:CS
|
code: "CS"
|
||||||
// {
|
},
|
||||||
// name: "Serbia and Montenegro",
|
|
||||||
// code: "CS"
|
|
||||||
// },
|
|
||||||
{
|
{
|
||||||
name: "Seychelles",
|
name: "Seychelles",
|
||||||
code: "SC"
|
code: "SC"
|
||||||
|
|||||||
+1
-36
@@ -1,12 +1,6 @@
|
|||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import { readFileSync } from "fs";
|
import { readFileSync } from "fs";
|
||||||
import {
|
import { clients, db, resources, siteResources } from "@server/db";
|
||||||
clients,
|
|
||||||
db,
|
|
||||||
resourcePolicies,
|
|
||||||
resources,
|
|
||||||
siteResources
|
|
||||||
} from "@server/db";
|
|
||||||
import { randomInt } from "crypto";
|
import { randomInt } from "crypto";
|
||||||
import { exitNodes, sites } from "@server/db";
|
import { exitNodes, sites } from "@server/db";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
@@ -113,35 +107,6 @@ export async function getUniqueResourceName(orgId: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUniqueResourcePolicyName(
|
|
||||||
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 policyCount = await db
|
|
||||||
.select({
|
|
||||||
niceId: resourcePolicies.niceId,
|
|
||||||
orgId: resourcePolicies.orgId
|
|
||||||
})
|
|
||||||
.from(resourcePolicies)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(resourcePolicies.niceId, name),
|
|
||||||
eq(resourcePolicies.orgId, orgId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
if (policyCount.length === 0) {
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
loops++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getUniqueSiteResourceName(
|
export async function getUniqueSiteResourceName(
|
||||||
orgId: string
|
orgId: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ function createDb() {
|
|||||||
|
|
||||||
export const db = createDb();
|
export const db = createDb();
|
||||||
export default db;
|
export default db;
|
||||||
export const primaryDb = db.$primary as typeof db; // is this typeof a problem - technically they are different types
|
export const primaryDb = db.$primary as typeof db; // is this typeof a problem - techincally they are different types
|
||||||
export type Transaction = Parameters<
|
export type Transaction = Parameters<
|
||||||
Parameters<(typeof db)["transaction"]>[0]
|
Parameters<(typeof db)["transaction"]>[0]
|
||||||
>[0];
|
>[0];
|
||||||
|
|||||||
@@ -4,4 +4,3 @@ export * from "./safeRead";
|
|||||||
export * from "./schema/schema";
|
export * from "./schema/schema";
|
||||||
export * from "./schema/privateSchema";
|
export * from "./schema/privateSchema";
|
||||||
export * from "./migrate";
|
export * from "./migrate";
|
||||||
export { alias } from "drizzle-orm/pg-core";
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { drizzle as DrizzlePostgres } from "drizzle-orm/node-postgres";
|
|||||||
import { readConfigFile } from "@server/lib/readConfigFile";
|
import { readConfigFile } from "@server/lib/readConfigFile";
|
||||||
import { withReplicas } from "drizzle-orm/pg-core";
|
import { withReplicas } from "drizzle-orm/pg-core";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { db as mainDb } from "./driver";
|
import { db as mainDb, primaryDb as mainPrimaryDb } from "./driver";
|
||||||
import { createPool } from "./poolConfig";
|
import { createPool } from "./poolConfig";
|
||||||
|
|
||||||
function createLogsDb() {
|
function createLogsDb() {
|
||||||
@@ -63,7 +63,8 @@ function createLogsDb() {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const maxReplicaConnections = poolConfig?.max_replica_connections || 20;
|
const maxReplicaConnections =
|
||||||
|
poolConfig?.max_replica_connections || 20;
|
||||||
for (const conn of replicaConnections) {
|
for (const conn of replicaConnections) {
|
||||||
const replicaPool = createPool(
|
const replicaPool = createPool(
|
||||||
conn.connection_string,
|
conn.connection_string,
|
||||||
@@ -90,4 +91,4 @@ function createLogsDb() {
|
|||||||
|
|
||||||
export const logsDb = createLogsDb();
|
export const logsDb = createLogsDb();
|
||||||
export default logsDb;
|
export default logsDb;
|
||||||
export const primaryLogsDb = logsDb.$primary;
|
export const primaryLogsDb = logsDb.$primary;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import config from "@server/lib/config";
|
|
||||||
import { Pool, PoolConfig } from "pg";
|
import { Pool, PoolConfig } from "pg";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
|
||||||
export function createPoolConfig(
|
export function createPoolConfig(
|
||||||
connectionString: string,
|
connectionString: string,
|
||||||
@@ -27,7 +27,7 @@ export function attachPoolErrorHandlers(pool: Pool, label: string): void {
|
|||||||
pool.on("error", (err) => {
|
pool.on("error", (err) => {
|
||||||
// This catches errors on idle clients in the pool. Without this
|
// This catches errors on idle clients in the pool. Without this
|
||||||
// handler an unexpected disconnect would crash the process.
|
// handler an unexpected disconnect would crash the process.
|
||||||
console.error(
|
logger.error(
|
||||||
`Unexpected error on idle ${label} database client: ${err.message}`
|
`Unexpected error on idle ${label} database client: ${err.message}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -36,32 +36,10 @@ export function attachPoolErrorHandlers(pool: Pool, label: string): void {
|
|||||||
// Set a statement timeout on every new connection so a single slow
|
// Set a statement timeout on every new connection so a single slow
|
||||||
// query can't block the pool forever
|
// query can't block the pool forever
|
||||||
client.query("SET statement_timeout = '30s'").catch((err: Error) => {
|
client.query("SET statement_timeout = '30s'").catch((err: Error) => {
|
||||||
console.warn(
|
logger.warn(
|
||||||
`Failed to set statement_timeout on ${label} client: ${err.message}`
|
`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}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,4 +60,4 @@ export function createPool(
|
|||||||
);
|
);
|
||||||
attachPoolErrorHandlers(pool, label);
|
attachPoolErrorHandlers(pool, label);
|
||||||
return pool;
|
return pool;
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
pgTable,
|
pgTable,
|
||||||
serial,
|
serial,
|
||||||
varchar,
|
varchar,
|
||||||
unique,
|
|
||||||
boolean,
|
boolean,
|
||||||
integer,
|
integer,
|
||||||
bigint,
|
bigint,
|
||||||
@@ -12,7 +11,7 @@ import {
|
|||||||
primaryKey,
|
primaryKey,
|
||||||
uniqueIndex
|
uniqueIndex
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { InferSelectModel, sql } from "drizzle-orm";
|
import { InferSelectModel } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
domains,
|
domains,
|
||||||
orgs,
|
orgs,
|
||||||
@@ -20,13 +19,12 @@ import {
|
|||||||
roles,
|
roles,
|
||||||
users,
|
users,
|
||||||
exitNodes,
|
exitNodes,
|
||||||
|
sessions,
|
||||||
|
clients,
|
||||||
resources,
|
resources,
|
||||||
siteResources,
|
siteResources,
|
||||||
targetHealthCheck,
|
targetHealthCheck,
|
||||||
sites,
|
sites
|
||||||
clients,
|
|
||||||
sessions,
|
|
||||||
labels
|
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
|
|
||||||
export const certificates = pgTable("certificates", {
|
export const certificates = pgTable("certificates", {
|
||||||
@@ -199,42 +197,6 @@ export const remoteExitNodes = pgTable("remoteExitNode", {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
export const remoteExitNodeResources = pgTable("remoteExitNodeResources", {
|
|
||||||
remoteExitNodeResourceId: serial("remoteExitNodeResourceId").primaryKey(),
|
|
||||||
remoteExitNodeId: varchar("remoteExitNodeId")
|
|
||||||
.notNull()
|
|
||||||
.references(() => remoteExitNodes.remoteExitNodeId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
}),
|
|
||||||
destination: varchar("destination").notNull() // a cidr range
|
|
||||||
});
|
|
||||||
|
|
||||||
export const remoteExitNodePreferenceLabels = pgTable(
|
|
||||||
// this controls what sites are enforced to connect to this node
|
|
||||||
"remoteExitNodePreferenceLabels",
|
|
||||||
{
|
|
||||||
remoteExitNodePreferenceLabelId: serial(
|
|
||||||
"remoteExitNodePreferenceLabelId"
|
|
||||||
).primaryKey(),
|
|
||||||
remoteExitNodeId: varchar("remoteExitNodeId")
|
|
||||||
.references(() => remoteExitNodes.remoteExitNodeId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
labelId: integer("labelId")
|
|
||||||
.references(() => labels.labelId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull()
|
|
||||||
},
|
|
||||||
(t) => [
|
|
||||||
unique("remote_exit_node_preference_label_uniq").on(
|
|
||||||
t.remoteExitNodeId,
|
|
||||||
t.labelId
|
|
||||||
)
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const remoteExitNodeSessions = pgTable("remoteExitNodeSession", {
|
export const remoteExitNodeSessions = pgTable("remoteExitNodeSession", {
|
||||||
sessionId: varchar("id").primaryKey(),
|
sessionId: varchar("id").primaryKey(),
|
||||||
remoteExitNodeId: varchar("remoteExitNodeId")
|
remoteExitNodeId: varchar("remoteExitNodeId")
|
||||||
@@ -245,28 +207,17 @@ export const remoteExitNodeSessions = pgTable("remoteExitNodeSession", {
|
|||||||
expiresAt: bigint("expiresAt", { mode: "number" }).notNull()
|
expiresAt: bigint("expiresAt", { mode: "number" }).notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const loginPage = pgTable(
|
export const loginPage = pgTable("loginPage", {
|
||||||
"loginPage",
|
loginPageId: serial("loginPageId").primaryKey(),
|
||||||
{
|
subdomain: varchar("subdomain"),
|
||||||
loginPageId: serial("loginPageId").primaryKey(),
|
fullDomain: varchar("fullDomain"),
|
||||||
subdomain: varchar("subdomain"),
|
exitNodeId: integer("exitNodeId").references(() => exitNodes.exitNodeId, {
|
||||||
fullDomain: varchar("fullDomain"),
|
onDelete: "set null"
|
||||||
exitNodeId: integer("exitNodeId").references(
|
}),
|
||||||
() => exitNodes.exitNodeId,
|
domainId: varchar("domainId").references(() => domains.domainId, {
|
||||||
{
|
onDelete: "set null"
|
||||||
onDelete: "set null"
|
})
|
||||||
}
|
});
|
||||||
),
|
|
||||||
domainId: varchar("domainId").references(() => domains.domainId, {
|
|
||||||
onDelete: "set null"
|
|
||||||
})
|
|
||||||
},
|
|
||||||
(t) => [
|
|
||||||
index("idx_loginpage_fulldomain")
|
|
||||||
.on(t.fullDomain)
|
|
||||||
.where(sql`${t.fullDomain} IS NOT NULL`)
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const loginPageOrg = pgTable("loginPageOrg", {
|
export const loginPageOrg = pgTable("loginPageOrg", {
|
||||||
loginPageId: integer("loginPageId")
|
loginPageId: integer("loginPageId")
|
||||||
|
|||||||
+320
-1139
File diff suppressed because it is too large
Load Diff
@@ -17,37 +17,22 @@ import {
|
|||||||
resourceHeaderAuth,
|
resourceHeaderAuth,
|
||||||
ResourceHeaderAuth,
|
ResourceHeaderAuth,
|
||||||
resourceRules,
|
resourceRules,
|
||||||
resourcePolicyRules,
|
|
||||||
resources,
|
resources,
|
||||||
roleResources,
|
roleResources,
|
||||||
rolePolicies,
|
|
||||||
sessions,
|
sessions,
|
||||||
userResources,
|
userResources,
|
||||||
userPolicies,
|
|
||||||
users,
|
users,
|
||||||
ResourceHeaderAuthExtendedCompatibility,
|
ResourceHeaderAuthExtendedCompatibility,
|
||||||
resourceHeaderAuthExtendedCompatibility,
|
resourceHeaderAuthExtendedCompatibility
|
||||||
resourcePolicies,
|
|
||||||
resourcePolicyPincode,
|
|
||||||
ResourcePolicyPincode,
|
|
||||||
resourcePolicyPassword,
|
|
||||||
ResourcePolicyPassword,
|
|
||||||
resourcePolicyHeaderAuth,
|
|
||||||
ResourcePolicyHeaderAuth
|
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { alias } from "@server/db";
|
import { and, eq, inArray, or, sql } from "drizzle-orm";
|
||||||
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
|
|
||||||
export type ResourceWithAuth = {
|
export type ResourceWithAuth = {
|
||||||
resource: Resource | null;
|
resource: Resource | null;
|
||||||
pincode: ResourcePincode | ResourcePolicyPincode | null;
|
pincode: ResourcePincode | null;
|
||||||
password: ResourcePassword | ResourcePolicyPassword | null;
|
password: ResourcePassword | null;
|
||||||
headerAuth: ResourceHeaderAuth | ResourcePolicyHeaderAuth | null;
|
headerAuth: ResourceHeaderAuth | null;
|
||||||
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
|
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
|
||||||
applyRules: boolean | null;
|
|
||||||
sso: boolean | null;
|
|
||||||
emailWhitelistEnabled: boolean | null;
|
|
||||||
org: Org;
|
org: Org;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -72,33 +57,6 @@ export async function getResourceByDomain(
|
|||||||
wildcardCandidates.push(`*.${parts.slice(i).join(".")}`);
|
wildcardCandidates.push(`*.${parts.slice(i).join(".")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sharedPolicy = alias(resourcePolicies, "sharedPolicy");
|
|
||||||
const defaultPolicy = alias(resourcePolicies, "defaultPolicy");
|
|
||||||
const sharedPolicyPincode = alias(
|
|
||||||
resourcePolicyPincode,
|
|
||||||
"sharedPolicyPincode"
|
|
||||||
);
|
|
||||||
const defaultPolicyPincode = alias(
|
|
||||||
resourcePolicyPincode,
|
|
||||||
"defaultPolicyPincode"
|
|
||||||
);
|
|
||||||
const sharedPolicyPassword = alias(
|
|
||||||
resourcePolicyPassword,
|
|
||||||
"sharedPolicyPassword"
|
|
||||||
);
|
|
||||||
const defaultPolicyPassword = alias(
|
|
||||||
resourcePolicyPassword,
|
|
||||||
"defaultPolicyPassword"
|
|
||||||
);
|
|
||||||
const sharedPolicyHeaderAuth = alias(
|
|
||||||
resourcePolicyHeaderAuth,
|
|
||||||
"sharedPolicyHeaderAuth"
|
|
||||||
);
|
|
||||||
const defaultPolicyHeaderAuth = alias(
|
|
||||||
resourcePolicyHeaderAuth,
|
|
||||||
"defaultPolicyHeaderAuth"
|
|
||||||
);
|
|
||||||
|
|
||||||
const potentialResults = await db
|
const potentialResults = await db
|
||||||
.select()
|
.select()
|
||||||
.from(resources)
|
.from(resources)
|
||||||
@@ -121,59 +79,6 @@ export async function getResourceByDomain(
|
|||||||
resources.resourceId
|
resources.resourceId
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.leftJoin(
|
|
||||||
sharedPolicy,
|
|
||||||
eq(sharedPolicy.resourcePolicyId, resources.resourcePolicyId)
|
|
||||||
)
|
|
||||||
.leftJoin(
|
|
||||||
sharedPolicyPincode,
|
|
||||||
eq(
|
|
||||||
sharedPolicyPincode.resourcePolicyId,
|
|
||||||
sharedPolicy.resourcePolicyId
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.leftJoin(
|
|
||||||
sharedPolicyPassword,
|
|
||||||
eq(
|
|
||||||
sharedPolicyPassword.resourcePolicyId,
|
|
||||||
sharedPolicy.resourcePolicyId
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.leftJoin(
|
|
||||||
sharedPolicyHeaderAuth,
|
|
||||||
eq(
|
|
||||||
sharedPolicyHeaderAuth.resourcePolicyId,
|
|
||||||
sharedPolicy.resourcePolicyId
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.leftJoin(
|
|
||||||
defaultPolicy,
|
|
||||||
eq(
|
|
||||||
defaultPolicy.resourcePolicyId,
|
|
||||||
resources.defaultResourcePolicyId
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.leftJoin(
|
|
||||||
defaultPolicyPincode,
|
|
||||||
eq(
|
|
||||||
defaultPolicyPincode.resourcePolicyId,
|
|
||||||
defaultPolicy.resourcePolicyId
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.leftJoin(
|
|
||||||
defaultPolicyPassword,
|
|
||||||
eq(
|
|
||||||
defaultPolicyPassword.resourcePolicyId,
|
|
||||||
defaultPolicy.resourcePolicyId
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.leftJoin(
|
|
||||||
defaultPolicyHeaderAuth,
|
|
||||||
eq(
|
|
||||||
defaultPolicyHeaderAuth.resourcePolicyId,
|
|
||||||
defaultPolicy.resourcePolicyId
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.innerJoin(orgs, eq(orgs.orgId, resources.orgId))
|
.innerJoin(orgs, eq(orgs.orgId, resources.orgId))
|
||||||
.where(
|
.where(
|
||||||
or(
|
or(
|
||||||
@@ -203,51 +108,13 @@ export async function getResourceByDomain(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If a shared (custom) policy is assigned to the resource, use ONLY
|
|
||||||
// its values — do not fall back to the default policy. The default
|
|
||||||
// policy is only consulted when no shared policy is assigned at all.
|
|
||||||
const hasSharedPolicy = result.sharedPolicy !== null;
|
|
||||||
|
|
||||||
const effectivePolicyPincode = hasSharedPolicy
|
|
||||||
? result.sharedPolicyPincode
|
|
||||||
: (result.defaultPolicyPincode ?? null);
|
|
||||||
const effectivePolicyPassword = hasSharedPolicy
|
|
||||||
? result.sharedPolicyPassword
|
|
||||||
: (result.defaultPolicyPassword ?? null);
|
|
||||||
const effectivePolicyHeaderAuth = hasSharedPolicy
|
|
||||||
? result.sharedPolicyHeaderAuth
|
|
||||||
: (result.defaultPolicyHeaderAuth ?? null);
|
|
||||||
const selectedPolicy = hasSharedPolicy
|
|
||||||
? result.sharedPolicy
|
|
||||||
: result.defaultPolicy;
|
|
||||||
const effectiveApplyRules =
|
|
||||||
selectedPolicy?.applyRules ?? result.resources.applyRules;
|
|
||||||
const effectiveSSO = selectedPolicy?.sso ?? result.resources.sso;
|
|
||||||
const effectiveEmailWhitelistEnabled =
|
|
||||||
selectedPolicy?.emailWhitelistEnabled ??
|
|
||||||
result.resources.emailWhitelistEnabled;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
resource: {
|
resource: result.resources,
|
||||||
...result.resources,
|
pincode: result.resourcePincode,
|
||||||
applyRules: effectiveApplyRules,
|
password: result.resourcePassword,
|
||||||
sso: effectiveSSO,
|
headerAuth: result.resourceHeaderAuth,
|
||||||
emailWhitelistEnabled: effectiveEmailWhitelistEnabled
|
headerAuthExtendedCompatibility:
|
||||||
}, // doing this for backward compatability so the remote nodes get the value as part of the resource struct
|
result.resourceHeaderAuthExtendedCompatibility,
|
||||||
pincode: effectivePolicyPincode ?? result.resourcePincode,
|
|
||||||
password: effectivePolicyPassword ?? result.resourcePassword,
|
|
||||||
headerAuth: effectivePolicyHeaderAuth ?? result.resourceHeaderAuth,
|
|
||||||
headerAuthExtendedCompatibility: effectivePolicyHeaderAuth
|
|
||||||
? ({
|
|
||||||
headerAuthExtendedCompatibilityId: 0,
|
|
||||||
resourceId: result.resources.resourceId,
|
|
||||||
extendedCompatibilityIsActivated:
|
|
||||||
effectivePolicyHeaderAuth.extendedCompatibility
|
|
||||||
} as ResourceHeaderAuthExtendedCompatibility)
|
|
||||||
: result.resourceHeaderAuthExtendedCompatibility,
|
|
||||||
applyRules: effectiveApplyRules,
|
|
||||||
sso: effectiveSSO,
|
|
||||||
emailWhitelistEnabled: effectiveEmailWhitelistEnabled,
|
|
||||||
org: result.orgs
|
org: result.orgs
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -287,165 +154,58 @@ export async function getRoleName(roleId: number): Promise<string | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if role has access to resource (direct or via resource policy)
|
* Check if role has access to resource
|
||||||
*/
|
*/
|
||||||
export async function getRoleResourceAccess(
|
export async function getRoleResourceAccess(
|
||||||
resourceId: number,
|
resourceId: number,
|
||||||
roleIds: number[]
|
roleIds: number[]
|
||||||
) {
|
) {
|
||||||
const [direct, viaPolicies] = await Promise.all([
|
const roleResourceAccess = await db
|
||||||
db
|
.select()
|
||||||
.select()
|
.from(roleResources)
|
||||||
.from(roleResources)
|
.where(
|
||||||
.where(
|
and(
|
||||||
and(
|
eq(roleResources.resourceId, resourceId),
|
||||||
eq(roleResources.resourceId, resourceId),
|
inArray(roleResources.roleId, roleIds)
|
||||||
inArray(roleResources.roleId, roleIds)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
db
|
|
||||||
.select({
|
|
||||||
roleId: rolePolicies.roleId,
|
|
||||||
resourcePolicyId: rolePolicies.resourcePolicyId
|
|
||||||
})
|
|
||||||
.from(rolePolicies)
|
|
||||||
.innerJoin(
|
|
||||||
resources,
|
|
||||||
// Shared policy wins; only use default policy when no shared
|
|
||||||
// policy is assigned to the resource.
|
|
||||||
or(
|
|
||||||
eq(
|
|
||||||
resources.resourcePolicyId,
|
|
||||||
rolePolicies.resourcePolicyId
|
|
||||||
),
|
|
||||||
and(
|
|
||||||
isNull(resources.resourcePolicyId),
|
|
||||||
eq(
|
|
||||||
resources.defaultResourcePolicyId,
|
|
||||||
rolePolicies.resourcePolicyId
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
.where(
|
);
|
||||||
and(
|
|
||||||
eq(resources.resourceId, resourceId),
|
|
||||||
inArray(rolePolicies.roleId, roleIds)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
]);
|
|
||||||
|
|
||||||
const combined = [...direct, ...viaPolicies];
|
return roleResourceAccess.length > 0 ? roleResourceAccess : null;
|
||||||
return combined.length > 0 ? combined : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if user has access to resource (direct or via resource policy)
|
* Check if user has direct access to resource
|
||||||
*/
|
*/
|
||||||
export async function getUserResourceAccess(
|
export async function getUserResourceAccess(
|
||||||
userId: string,
|
userId: string,
|
||||||
resourceId: number
|
resourceId: number
|
||||||
) {
|
) {
|
||||||
const [direct, viaPolicies] = await Promise.all([
|
const userResourceAccess = await db
|
||||||
db
|
.select()
|
||||||
.select()
|
.from(userResources)
|
||||||
.from(userResources)
|
.where(
|
||||||
.where(
|
and(
|
||||||
and(
|
eq(userResources.userId, userId),
|
||||||
eq(userResources.userId, userId),
|
eq(userResources.resourceId, resourceId)
|
||||||
eq(userResources.resourceId, resourceId)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
.limit(1),
|
)
|
||||||
db
|
.limit(1);
|
||||||
.select({
|
|
||||||
userId: userPolicies.userId,
|
|
||||||
resourcePolicyId: userPolicies.resourcePolicyId
|
|
||||||
})
|
|
||||||
.from(userPolicies)
|
|
||||||
.innerJoin(
|
|
||||||
resources,
|
|
||||||
// Shared policy wins; only use default policy when no shared
|
|
||||||
// policy is assigned to the resource.
|
|
||||||
or(
|
|
||||||
eq(
|
|
||||||
resources.resourcePolicyId,
|
|
||||||
userPolicies.resourcePolicyId
|
|
||||||
),
|
|
||||||
and(
|
|
||||||
isNull(resources.resourcePolicyId),
|
|
||||||
eq(
|
|
||||||
resources.defaultResourcePolicyId,
|
|
||||||
userPolicies.resourcePolicyId
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(resources.resourceId, resourceId),
|
|
||||||
eq(userPolicies.userId, userId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
]);
|
|
||||||
|
|
||||||
return direct[0] ?? viaPolicies[0] ?? null;
|
return userResourceAccess.length > 0 ? userResourceAccess[0] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get resource rules for a given resource (direct and via resource policy)
|
* Get resource rules for a given resource
|
||||||
*/
|
*/
|
||||||
export async function getResourceRules(
|
export async function getResourceRules(
|
||||||
resourceId: number
|
resourceId: number
|
||||||
): Promise<ResourceRule[]> {
|
): Promise<ResourceRule[]> {
|
||||||
const [directRules, policyRules] = await Promise.all([
|
const rules = await db
|
||||||
db
|
.select()
|
||||||
.select()
|
.from(resourceRules)
|
||||||
.from(resourceRules)
|
.where(eq(resourceRules.resourceId, resourceId));
|
||||||
.where(eq(resourceRules.resourceId, resourceId)),
|
|
||||||
db
|
|
||||||
.select({
|
|
||||||
ruleId: resourcePolicyRules.ruleId,
|
|
||||||
resourceId: sql<number>`${resourceId}`,
|
|
||||||
enabled: resourcePolicyRules.enabled,
|
|
||||||
priority: resourcePolicyRules.priority,
|
|
||||||
action: resourcePolicyRules.action,
|
|
||||||
match: resourcePolicyRules.match,
|
|
||||||
value: resourcePolicyRules.value
|
|
||||||
})
|
|
||||||
.from(resourcePolicyRules)
|
|
||||||
.innerJoin(
|
|
||||||
resources,
|
|
||||||
// Shared policy wins; only use default policy when no shared
|
|
||||||
// policy is assigned to the resource.
|
|
||||||
or(
|
|
||||||
eq(
|
|
||||||
resources.resourcePolicyId,
|
|
||||||
resourcePolicyRules.resourcePolicyId
|
|
||||||
),
|
|
||||||
and(
|
|
||||||
isNull(resources.resourcePolicyId),
|
|
||||||
eq(
|
|
||||||
resources.defaultResourcePolicyId,
|
|
||||||
resourcePolicyRules.resourcePolicyId
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.where(eq(resources.resourceId, resourceId))
|
|
||||||
]);
|
|
||||||
|
|
||||||
const maxDirectPriority = directRules.reduce(
|
return rules;
|
||||||
(max, r) => Math.max(max, r.priority),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
const offsetPolicyRules = policyRules.map((r) => ({
|
|
||||||
...r,
|
|
||||||
priority: maxDirectPriority + r.priority
|
|
||||||
}));
|
|
||||||
|
|
||||||
return [...directRules, ...offsetPolicyRules] as ResourceRule[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+51
-18
@@ -1,46 +1,79 @@
|
|||||||
import { drizzle as DrizzleSqlite } from "drizzle-orm/better-sqlite3";
|
import { drizzle as DrizzleSqlite } from "drizzle-orm/better-sqlite3";
|
||||||
import Database from "better-sqlite3";
|
import Database from "better-sqlite3";
|
||||||
|
import type BetterSqlite3 from "better-sqlite3";
|
||||||
import * as schema from "./schema/schema";
|
import * as schema from "./schema/schema";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import { APP_PATH } from "@server/lib/consts";
|
import { APP_PATH } from "@server/lib/consts";
|
||||||
import { existsSync, mkdirSync } from "fs";
|
import { existsSync, mkdirSync } from "fs";
|
||||||
import logger from "@server/logger";
|
|
||||||
|
|
||||||
export const location = path.join(APP_PATH, "db", "db.sqlite");
|
export const location = path.join(APP_PATH, "db", "db.sqlite");
|
||||||
export const exists = checkFileExists(location);
|
export const exists = checkFileExists(location);
|
||||||
|
|
||||||
bootstrapVolume();
|
bootstrapVolume();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps better-sqlite3 Statement to call `finalize()` immediately after
|
||||||
|
* execution, freeing native sqlite3_stmt memory deterministically instead
|
||||||
|
* of waiting for GC. Fixes steady off-heap growth under load (#2120).
|
||||||
|
* WARNING: Finalizes after first execution — incompatible with drizzle's
|
||||||
|
* reusable .prepare() builders. No such usage exists in this codebase.
|
||||||
|
*/
|
||||||
|
function autoFinalizeStatement(
|
||||||
|
stmt: BetterSqlite3.Statement
|
||||||
|
): BetterSqlite3.Statement {
|
||||||
|
const wrapExec = <T extends (...args: any[]) => any>(fn: T): T => {
|
||||||
|
return function (this: any, ...args: any[]) {
|
||||||
|
try {
|
||||||
|
return fn.apply(this, args);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
// finalize() exists on the native Statement at runtime but
|
||||||
|
// is missing from @types/better-sqlite3.
|
||||||
|
(stmt as any).finalize();
|
||||||
|
} catch {
|
||||||
|
// Already finalized — harmless
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as unknown as T;
|
||||||
|
};
|
||||||
|
|
||||||
|
stmt.run = wrapExec(stmt.run);
|
||||||
|
stmt.get = wrapExec(stmt.get);
|
||||||
|
stmt.all = wrapExec(stmt.all);
|
||||||
|
|
||||||
|
return stmt;
|
||||||
|
}
|
||||||
|
|
||||||
function createDb() {
|
function createDb() {
|
||||||
const verbose =
|
const sqlite = new Database(location);
|
||||||
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") {
|
if (process.env.ENABLE_SQLITE_WAL_MODE == "true") {
|
||||||
// Enable WAL mode — allows concurrent readers + single writer, preventing
|
// Enable WAL mode — allows concurrent readers + single writer, preventing
|
||||||
// contention across subsystems (verifySession, Traefik, audit, ping).
|
// contention across subsystems (verifySession, Traefik, audit, ping).
|
||||||
// NOTE: journal_mode persists in the DB file once set; unsetting this
|
|
||||||
// env var does NOT revert an existing WAL database.
|
|
||||||
sqlite.pragma("journal_mode = WAL");
|
sqlite.pragma("journal_mode = WAL");
|
||||||
// NORMAL sync mode: safe with WAL, reduces write lock hold time.
|
// NORMAL sync mode: safe with WAL, reduces write lock hold time.
|
||||||
sqlite.pragma("synchronous = NORMAL");
|
sqlite.pragma("synchronous = NORMAL");
|
||||||
}
|
}
|
||||||
|
|
||||||
// No busy_timeout pragma: better-sqlite3 already arms
|
// Wait up to 5s on SQLITE_BUSY instead of failing — prevents audit log
|
||||||
// sqlite3_busy_timeout(db, 5000) via its default `timeout` option
|
// retry loops that accumulate memory.
|
||||||
// (lib/database.js), so an explicit pragma is redundant.
|
sqlite.pragma("busy_timeout = 5000");
|
||||||
|
|
||||||
// Intentionally NOT setting cache_size or mmap_size: a large page cache plus
|
// 64 MB page cache (default 2 MB) — reduces I/O round-trips on large
|
||||||
// a multi-hundred-MB mmap region inflate RSS and cause page-cache thrashing
|
// TraefikConfigManager JOINs that block the event loop.
|
||||||
// on small (~1 GB) instances. Leave SQLite on its conservative defaults.
|
sqlite.pragma("cache_size = -65536");
|
||||||
|
|
||||||
// Intentionally NOT wrapping prepare()/statements: better-sqlite3 finalizes
|
// 256 MB memory-mapped I/O — OS serves reads from page cache directly,
|
||||||
// sqlite3_stmt in the Statement destructor at GC, and drizzle-orm prepares a
|
// reducing event-loop blocking.
|
||||||
// fresh statement per query (no statement cache), so statements cannot
|
sqlite.pragma("mmap_size = 268435456");
|
||||||
// accumulate. better-sqlite3 11.x exposes no Statement.finalize() at all.
|
|
||||||
|
// Wrap prepare() so every drizzle-orm statement is auto-finalized after
|
||||||
|
// first use, preventing sqlite3_stmt accumulation between GC cycles.
|
||||||
|
const originalPrepare = sqlite.prepare.bind(sqlite);
|
||||||
|
(sqlite as any).prepare = function autoFinalizePrepare(source: string) {
|
||||||
|
return autoFinalizeStatement(originalPrepare(source));
|
||||||
|
};
|
||||||
|
|
||||||
return DrizzleSqlite(sqlite, {
|
return DrizzleSqlite(sqlite, {
|
||||||
schema
|
schema
|
||||||
|
|||||||
@@ -4,4 +4,3 @@ export * from "./safeRead";
|
|||||||
export * from "./schema/schema";
|
export * from "./schema/schema";
|
||||||
export * from "./schema/privateSchema";
|
export * from "./schema/privateSchema";
|
||||||
export * from "./migrate";
|
export * from "./migrate";
|
||||||
export { alias } from "drizzle-orm/sqlite-core";
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
clients,
|
clients,
|
||||||
domains,
|
domains,
|
||||||
exitNodes,
|
exitNodes,
|
||||||
labels,
|
|
||||||
orgs,
|
orgs,
|
||||||
resources,
|
resources,
|
||||||
roles,
|
roles,
|
||||||
@@ -22,6 +21,9 @@ import {
|
|||||||
targetHealthCheck,
|
targetHealthCheck,
|
||||||
users
|
users
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
|
import { serial, varchar } from "drizzle-orm/mysql-core";
|
||||||
|
import { pgTable } from "drizzle-orm/pg-core";
|
||||||
|
import { bigint } from "zod";
|
||||||
|
|
||||||
export const certificates = sqliteTable("certificates", {
|
export const certificates = sqliteTable("certificates", {
|
||||||
certId: integer("certId").primaryKey({ autoIncrement: true }),
|
certId: integer("certId").primaryKey({ autoIncrement: true }),
|
||||||
@@ -193,44 +195,6 @@ export const remoteExitNodes = sqliteTable("remoteExitNode", {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
export const remoteExitNodeResources = sqliteTable("remoteExitNodeResources", {
|
|
||||||
remoteExitNodeResourceId: integer("remoteExitNodeResourceId").primaryKey({
|
|
||||||
autoIncrement: true
|
|
||||||
}),
|
|
||||||
remoteExitNodeId: text("remoteExitNodeId")
|
|
||||||
.notNull()
|
|
||||||
.references(() => remoteExitNodes.remoteExitNodeId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
}),
|
|
||||||
destination: text("destination").notNull() // a cidr range
|
|
||||||
});
|
|
||||||
|
|
||||||
export const remoteExitNodePreferenceLabels = sqliteTable(
|
|
||||||
// this controls what sites are enforced to connect to this node
|
|
||||||
"remoteExitNodePreferenceLabels",
|
|
||||||
{
|
|
||||||
remoteExitNodePreferenceLabelId: integer(
|
|
||||||
"remoteExitNodePreferenceLabelId"
|
|
||||||
).primaryKey({ autoIncrement: true }),
|
|
||||||
remoteExitNodeId: text("remoteExitNodeId")
|
|
||||||
.references(() => remoteExitNodes.remoteExitNodeId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
labelId: integer("labelId")
|
|
||||||
.references(() => labels.labelId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull()
|
|
||||||
},
|
|
||||||
(t) => [
|
|
||||||
uniqueIndex("remote_exit_node_preference_label_uniq").on(
|
|
||||||
t.remoteExitNodeId,
|
|
||||||
t.labelId
|
|
||||||
)
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const remoteExitNodeSessions = sqliteTable("remoteExitNodeSession", {
|
export const remoteExitNodeSessions = sqliteTable("remoteExitNodeSession", {
|
||||||
sessionId: text("id").primaryKey(),
|
sessionId: text("id").primaryKey(),
|
||||||
remoteExitNodeId: text("remoteExitNodeId")
|
remoteExitNodeId: text("remoteExitNodeId")
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import { InferSelectModel, sql } from "drizzle-orm";
|
import { InferSelectModel } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
check,
|
|
||||||
index,
|
index,
|
||||||
integer,
|
integer,
|
||||||
primaryKey,
|
primaryKey,
|
||||||
real,
|
|
||||||
sqliteTable,
|
sqliteTable,
|
||||||
text,
|
text,
|
||||||
unique,
|
unique
|
||||||
uniqueIndex
|
|
||||||
} from "drizzle-orm/sqlite-core";
|
} from "drizzle-orm/sqlite-core";
|
||||||
|
|
||||||
export const domains = sqliteTable("domains", {
|
export const domains = sqliteTable("domains", {
|
||||||
@@ -18,15 +15,13 @@ export const domains = sqliteTable("domains", {
|
|||||||
configManaged: integer("configManaged", { mode: "boolean" })
|
configManaged: integer("configManaged", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
type: text("type").$type<"ns" | "cname" | "wildcard">(),
|
type: text("type"), // "ns", "cname", "wildcard"
|
||||||
verified: integer("verified", { mode: "boolean" }).notNull().default(false),
|
verified: integer("verified", { mode: "boolean" }).notNull().default(false),
|
||||||
failed: integer("failed", { mode: "boolean" }).notNull().default(false),
|
failed: integer("failed", { mode: "boolean" }).notNull().default(false),
|
||||||
tries: integer("tries").notNull().default(0),
|
tries: integer("tries").notNull().default(0),
|
||||||
certResolver: text("certResolver"),
|
certResolver: text("certResolver"),
|
||||||
customCertResolver: text("customCertResolver"),
|
|
||||||
preferWildcardCert: integer("preferWildcardCert", { mode: "boolean" }),
|
preferWildcardCert: integer("preferWildcardCert", { mode: "boolean" }),
|
||||||
errorMessage: text("errorMessage"),
|
errorMessage: text("errorMessage")
|
||||||
lastCheckedAt: integer("lastCheckedAt")
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const dnsRecords = sqliteTable("dnsRecords", {
|
export const dnsRecords = sqliteTable("dnsRecords", {
|
||||||
@@ -67,13 +62,7 @@ export const orgs = sqliteTable("orgs", {
|
|||||||
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
|
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
|
||||||
sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format)
|
sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format)
|
||||||
isBillingOrg: integer("isBillingOrg", { mode: "boolean" }),
|
isBillingOrg: integer("isBillingOrg", { mode: "boolean" }),
|
||||||
billingOrgId: text("billingOrgId"),
|
billingOrgId: text("billingOrgId")
|
||||||
settingsEnableGlobalNewtAutoUpdate: integer(
|
|
||||||
"settingsEnableGlobalNewtAutoUpdate",
|
|
||||||
{ mode: "boolean" }
|
|
||||||
)
|
|
||||||
.notNull()
|
|
||||||
.default(false)
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const userDomains = sqliteTable("userDomains", {
|
export const userDomains = sqliteTable("userDomains", {
|
||||||
@@ -110,7 +99,7 @@ export const sites = sqliteTable("sites", {
|
|||||||
}),
|
}),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
pubKey: text("pubKey"),
|
pubKey: text("pubKey"),
|
||||||
exitNodeSubnet: text("exitNodeSubnet"),
|
subnet: text("subnet"),
|
||||||
megabytesIn: integer("bytesIn").default(0),
|
megabytesIn: integer("bytesIn").default(0),
|
||||||
megabytesOut: integer("bytesOut").default(0),
|
megabytesOut: integer("bytesOut").default(0),
|
||||||
lastBandwidthUpdate: text("lastBandwidthUpdate"),
|
lastBandwidthUpdate: text("lastBandwidthUpdate"),
|
||||||
@@ -121,36 +110,17 @@ export const sites = sqliteTable("sites", {
|
|||||||
// exit node stuff that is how to connect to the site when it has a wg server
|
// 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
|
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
|
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
|
publicKey: text("publicKey"), // TODO: Fix typo in publicKey
|
||||||
lastHolePunch: integer("lastHolePunch"),
|
lastHolePunch: integer("lastHolePunch"),
|
||||||
listenPort: integer("listenPort"),
|
listenPort: integer("listenPort"),
|
||||||
dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" })
|
dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(true),
|
.default(true),
|
||||||
autoUpdateEnabled: integer("autoUpdateEnabled", { mode: "boolean" })
|
|
||||||
.notNull()
|
|
||||||
.default(false),
|
|
||||||
autoUpdateOverrideOrg: integer("autoUpdateOverrideOrg", {
|
|
||||||
mode: "boolean"
|
|
||||||
})
|
|
||||||
.notNull()
|
|
||||||
.default(false),
|
|
||||||
status: text("status").$type<"pending" | "approved">().default("approved")
|
status: text("status").$type<"pending" | "approved">().default("approved")
|
||||||
});
|
});
|
||||||
|
|
||||||
export const resources = sqliteTable("resources", {
|
export const resources = sqliteTable("resources", {
|
||||||
resourceId: integer("resourceId").primaryKey({ autoIncrement: true }),
|
resourceId: integer("resourceId").primaryKey({ autoIncrement: true }),
|
||||||
resourcePolicyId: integer("resourcePolicyId").references(
|
|
||||||
() => resourcePolicies.resourcePolicyId,
|
|
||||||
{ onDelete: "set null" }
|
|
||||||
),
|
|
||||||
defaultResourcePolicyId: integer("defaultResourcePolicyId").references(
|
|
||||||
() => resourcePolicies.resourcePolicyId,
|
|
||||||
{
|
|
||||||
onDelete: "restrict"
|
|
||||||
}
|
|
||||||
),
|
|
||||||
resourceGuid: text("resourceGuid", { length: 36 })
|
resourceGuid: text("resourceGuid", { length: 36 })
|
||||||
.unique()
|
.unique()
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -171,12 +141,16 @@ export const resources = sqliteTable("resources", {
|
|||||||
blockAccess: integer("blockAccess", { mode: "boolean" })
|
blockAccess: integer("blockAccess", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
|
sso: integer("sso", { mode: "boolean" }).notNull().default(true),
|
||||||
|
http: integer("http", { mode: "boolean" }).notNull().default(true),
|
||||||
|
protocol: text("protocol").notNull(),
|
||||||
proxyPort: integer("proxyPort"),
|
proxyPort: integer("proxyPort"),
|
||||||
sso: integer("sso", { mode: "boolean" }),
|
emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" })
|
||||||
emailWhitelistEnabled: integer("emailWhitelistEnabled", {
|
.notNull()
|
||||||
mode: "boolean"
|
.default(false),
|
||||||
}),
|
applyRules: integer("applyRules", { mode: "boolean" })
|
||||||
applyRules: integer("applyRules", { mode: "boolean" }),
|
.notNull()
|
||||||
|
.default(false),
|
||||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||||
stickySession: integer("stickySession", { mode: "boolean" })
|
stickySession: integer("stickySession", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -192,6 +166,7 @@ export const resources = sqliteTable("resources", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
proxyProtocolVersion: integer("proxyProtocolVersion").default(1),
|
proxyProtocolVersion: integer("proxyProtocolVersion").default(1),
|
||||||
|
|
||||||
maintenanceModeEnabled: integer("maintenanceModeEnabled", {
|
maintenanceModeEnabled: integer("maintenanceModeEnabled", {
|
||||||
mode: "boolean"
|
mode: "boolean"
|
||||||
})
|
})
|
||||||
@@ -205,170 +180,16 @@ export const resources = sqliteTable("resources", {
|
|||||||
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
|
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
|
||||||
postAuthPath: text("postAuthPath"),
|
postAuthPath: text("postAuthPath"),
|
||||||
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown"
|
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown"
|
||||||
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false),
|
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false)
|
||||||
mode: text("mode")
|
|
||||||
.default("http")
|
|
||||||
.$type<"rdp" | "ssh" | "http" | "vnc" | "inference" | "tcp" | "udp">()
|
|
||||||
.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),
|
|
||||||
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(),
|
|
||||||
color: text("color").notNull(),
|
|
||||||
orgId: text("orgId")
|
|
||||||
.references(() => orgs.orgId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.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",
|
|
||||||
{
|
|
||||||
siteLabelId: integer("siteLabelId").primaryKey({ autoIncrement: true }),
|
|
||||||
siteId: integer("siteId")
|
|
||||||
.references(() => sites.siteId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
labelId: integer("labelId")
|
|
||||||
.references(() => labels.labelId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull()
|
|
||||||
},
|
|
||||||
(t) => [unique("site_label_uniq").on(t.siteId, t.labelId)]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const resourceLabels = sqliteTable(
|
|
||||||
"resourceLabels",
|
|
||||||
{
|
|
||||||
resourceLabelId: integer("resourceLabelId").primaryKey({
|
|
||||||
autoIncrement: true
|
|
||||||
}),
|
|
||||||
resourceId: integer("resourceId")
|
|
||||||
.references(() => resources.resourceId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
labelId: integer("labelId")
|
|
||||||
.references(() => labels.labelId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull()
|
|
||||||
},
|
|
||||||
(t) => [unique("resource_label_uniq").on(t.resourceId, t.labelId)]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const siteResourceLabels = sqliteTable(
|
|
||||||
"siteResourceLabels",
|
|
||||||
{
|
|
||||||
siteResourceLabelId: integer("siteResourceLabelId").primaryKey({
|
|
||||||
autoIncrement: true
|
|
||||||
}),
|
|
||||||
siteResourceId: integer("siteResourceId")
|
|
||||||
.references(() => siteResources.siteResourceId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
labelId: integer("labelId")
|
|
||||||
.references(() => labels.labelId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull()
|
|
||||||
},
|
|
||||||
(t) => [unique("site_resource_label_uniq").on(t.siteResourceId, t.labelId)]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const clientLabels = sqliteTable(
|
|
||||||
"clientLabels",
|
|
||||||
{
|
|
||||||
clientLabelId: integer("clientLabelId").primaryKey({
|
|
||||||
autoIncrement: true
|
|
||||||
}),
|
|
||||||
clientId: integer("clientId")
|
|
||||||
.references(() => clients.clientId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
labelId: integer("labelId")
|
|
||||||
.references(() => labels.labelId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull()
|
|
||||||
},
|
|
||||||
(t) => [unique("client_label_uniq").on(t.clientId, t.labelId)]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const targets = sqliteTable("targets", {
|
export const targets = sqliteTable("targets", {
|
||||||
targetId: integer("targetId").primaryKey({ autoIncrement: true }),
|
targetId: integer("targetId").primaryKey({ autoIncrement: true }),
|
||||||
resourceId: integer("resourceId").references(() => resources.resourceId, {
|
resourceId: integer("resourceId")
|
||||||
onDelete: "cascade"
|
.references(() => resources.resourceId, {
|
||||||
}),
|
onDelete: "cascade"
|
||||||
providerId: integer("providerId").references(() => aiProviders.providerId, {
|
})
|
||||||
onDelete: "cascade"
|
.notNull(),
|
||||||
}),
|
|
||||||
siteId: integer("siteId")
|
siteId: integer("siteId")
|
||||||
.references(() => sites.siteId, {
|
.references(() => sites.siteId, {
|
||||||
onDelete: "cascade"
|
onDelete: "cascade"
|
||||||
@@ -383,12 +204,7 @@ export const targets = sqliteTable("targets", {
|
|||||||
pathMatchType: text("pathMatchType"), // exact, prefix, regex
|
pathMatchType: text("pathMatchType"), // exact, prefix, regex
|
||||||
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
|
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
|
||||||
rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix
|
rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix
|
||||||
priority: integer("priority").notNull().default(100),
|
priority: integer("priority").notNull().default(100)
|
||||||
mode: text("mode")
|
|
||||||
.$type<"http" | "tcp" | "udp" | "ssh" | "rdp" | "vnc">()
|
|
||||||
.notNull()
|
|
||||||
.default("http"),
|
|
||||||
authToken: text("authToken")
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const targetHealthCheck = sqliteTable("targetHealthCheck", {
|
export const targetHealthCheck = sqliteTable("targetHealthCheck", {
|
||||||
@@ -403,11 +219,9 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", {
|
|||||||
onDelete: "cascade"
|
onDelete: "cascade"
|
||||||
})
|
})
|
||||||
.notNull(),
|
.notNull(),
|
||||||
siteId: integer("siteId")
|
siteId: integer("siteId").references(() => sites.siteId, {
|
||||||
.references(() => sites.siteId, {
|
onDelete: "cascade"
|
||||||
onDelete: "cascade"
|
}).notNull(),
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
name: text("name"),
|
name: text("name"),
|
||||||
hcEnabled: integer("hcEnabled", { mode: "boolean" })
|
hcEnabled: integer("hcEnabled", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -464,21 +278,14 @@ export const siteResources = sqliteTable("siteResources", {
|
|||||||
() => networks.networkId,
|
() => networks.networkId,
|
||||||
{ onDelete: "restrict" }
|
{ onDelete: "restrict" }
|
||||||
),
|
),
|
||||||
requiresExitNodeConnection: integer("requiresExitNodeConnection", {
|
|
||||||
mode: "boolean"
|
|
||||||
})
|
|
||||||
.notNull()
|
|
||||||
.default(false),
|
|
||||||
niceId: text("niceId").notNull(),
|
niceId: text("niceId").notNull(),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
|
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
|
||||||
mode: text("mode")
|
mode: text("mode").$type<"host" | "cidr" | "http">().notNull(), // "host" | "cidr" | "http"
|
||||||
.$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
|
scheme: text("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
|
||||||
proxyPort: integer("proxyPort"), // only for port mode
|
proxyPort: integer("proxyPort"), // only for port mode
|
||||||
destinationPort: integer("destinationPort"), // only for port mode
|
destinationPort: integer("destinationPort"), // only for port mode
|
||||||
destination: text("destination"), // ip, cidr, hostname
|
destination: text("destination").notNull(), // ip, cidr, hostname
|
||||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||||
alias: text("alias"),
|
alias: text("alias"),
|
||||||
aliasAddress: text("aliasAddress"),
|
aliasAddress: text("aliasAddress"),
|
||||||
@@ -488,59 +295,16 @@ export const siteResources = sqliteTable("siteResources", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
authDaemonPort: integer("authDaemonPort").default(22123),
|
authDaemonPort: integer("authDaemonPort").default(22123),
|
||||||
pamMode: text("pamMode")
|
|
||||||
.$type<"passthrough" | "push">()
|
|
||||||
.default("passthrough"),
|
|
||||||
authDaemonMode: text("authDaemonMode")
|
authDaemonMode: text("authDaemonMode")
|
||||||
.$type<"site" | "remote" | "native">()
|
.$type<"site" | "remote">()
|
||||||
.default("site"),
|
.default("site"),
|
||||||
domainId: text("domainId").references(() => domains.domainId, {
|
domainId: text("domainId").references(() => domains.domainId, {
|
||||||
onDelete: "set null"
|
onDelete: "set null"
|
||||||
}),
|
}),
|
||||||
subdomain: text("subdomain"),
|
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", {
|
export const networks = sqliteTable("networks", {
|
||||||
networkId: integer("networkId").primaryKey({ autoIncrement: true }),
|
networkId: integer("networkId").primaryKey({ autoIncrement: true }),
|
||||||
niceId: text("niceId"),
|
niceId: text("niceId"),
|
||||||
@@ -687,7 +451,6 @@ export const clients = sqliteTable("clients", {
|
|||||||
pubKey: text("pubKey"),
|
pubKey: text("pubKey"),
|
||||||
olmId: text("olmId"), // to lock it to a specific olm optionally
|
olmId: text("olmId"), // to lock it to a specific olm optionally
|
||||||
subnet: text("subnet").notNull(),
|
subnet: text("subnet").notNull(),
|
||||||
exitNodeSubnet: text("exitNodeSubnet"), // this is the subnet when connecting to an exit node
|
|
||||||
megabytesIn: integer("bytesIn"),
|
megabytesIn: integer("bytesIn"),
|
||||||
megabytesOut: integer("bytesOut"),
|
megabytesOut: integer("bytesOut"),
|
||||||
lastBandwidthUpdate: text("lastBandwidthUpdate"),
|
lastBandwidthUpdate: text("lastBandwidthUpdate"),
|
||||||
@@ -1146,47 +909,6 @@ export const resourceHeaderAuth = sqliteTable("resourceHeaderAuth", {
|
|||||||
headerAuthHash: text("headerAuthHash").notNull()
|
headerAuthHash: text("headerAuthHash").notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const resourcePolicyPincode = sqliteTable("resourcePolicyPincode", {
|
|
||||||
pincodeId: integer("pincodeId").primaryKey({ autoIncrement: true }),
|
|
||||||
pincodeHash: text("pincodeHash").notNull(),
|
|
||||||
digitLength: integer("digitLength").notNull(),
|
|
||||||
resourcePolicyId: integer("resourcePolicyId")
|
|
||||||
.notNull()
|
|
||||||
.references(() => resourcePolicies.resourcePolicyId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
export const resourcePolicyPassword = sqliteTable("resourcePolicyPassword", {
|
|
||||||
passwordId: integer("passwordId").primaryKey({ autoIncrement: true }),
|
|
||||||
passwordHash: text("passwordHash").notNull(),
|
|
||||||
resourcePolicyId: integer("resourcePolicyId")
|
|
||||||
.notNull()
|
|
||||||
.references(() => resourcePolicies.resourcePolicyId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
export const resourcePolicyHeaderAuth = sqliteTable(
|
|
||||||
"resourcePolicyHeaderAuth",
|
|
||||||
{
|
|
||||||
headerAuthId: integer("headerAuthId").primaryKey({
|
|
||||||
autoIncrement: true
|
|
||||||
}),
|
|
||||||
headerAuthHash: text("headerAuthHash").notNull(),
|
|
||||||
extendedCompatibility: integer("extendedCompatibility", {
|
|
||||||
mode: "boolean"
|
|
||||||
})
|
|
||||||
.notNull()
|
|
||||||
.default(true),
|
|
||||||
resourcePolicyId: integer("resourcePolicyId")
|
|
||||||
.notNull()
|
|
||||||
.references(() => resourcePolicies.resourcePolicyId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export const resourceHeaderAuthExtendedCompatibility = sqliteTable(
|
export const resourceHeaderAuthExtendedCompatibility = sqliteTable(
|
||||||
"resourceHeaderAuthExtendedCompatibility",
|
"resourceHeaderAuthExtendedCompatibility",
|
||||||
{
|
{
|
||||||
@@ -1215,18 +937,11 @@ export const resourceAccessToken = sqliteTable("resourceAccessToken", {
|
|||||||
resourceId: integer("resourceId")
|
resourceId: integer("resourceId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||||
userId: text("userId").references(() => users.userId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
}),
|
|
||||||
path: text("path"),
|
|
||||||
tokenHash: text("tokenHash").notNull(),
|
tokenHash: text("tokenHash").notNull(),
|
||||||
sessionLength: integer("sessionLength").notNull(),
|
sessionLength: integer("sessionLength").notNull(),
|
||||||
expiresAt: integer("expiresAt"),
|
expiresAt: integer("expiresAt"),
|
||||||
title: text("title"),
|
title: text("title"),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
persistSession: integer("persistSession", { mode: "boolean" })
|
|
||||||
.notNull()
|
|
||||||
.default(false),
|
|
||||||
createdAt: integer("createdAt").notNull()
|
createdAt: integer("createdAt").notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1268,24 +983,6 @@ export const resourceSessions = sqliteTable("resourceSessions", {
|
|||||||
onDelete: "cascade"
|
onDelete: "cascade"
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
policyPasswordId: integer("policyPasswordId").references(
|
|
||||||
() => resourcePolicyPassword.passwordId,
|
|
||||||
{
|
|
||||||
onDelete: "cascade"
|
|
||||||
}
|
|
||||||
),
|
|
||||||
policyPincodeId: integer("policyPincodeId").references(
|
|
||||||
() => resourcePolicyPincode.pincodeId,
|
|
||||||
{
|
|
||||||
onDelete: "cascade"
|
|
||||||
}
|
|
||||||
),
|
|
||||||
policyWhitelistId: integer("policyWhitelistId").references(
|
|
||||||
() => resourcePolicyWhiteList.whitelistId,
|
|
||||||
{
|
|
||||||
onDelete: "cascade"
|
|
||||||
}
|
|
||||||
),
|
|
||||||
issuedAt: integer("issuedAt")
|
issuedAt: integer("issuedAt")
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1322,101 +1019,10 @@ export const resourceRules = sqliteTable("resourceRules", {
|
|||||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||||
priority: integer("priority").notNull(),
|
priority: integer("priority").notNull(),
|
||||||
action: text("action").notNull(), // ACCEPT, DROP, PASS
|
action: text("action").notNull(), // ACCEPT, DROP, PASS
|
||||||
match: text("match")
|
match: text("match").notNull(), // CIDR, PATH, IP
|
||||||
.$type<
|
|
||||||
| "CIDR"
|
|
||||||
| "PATH"
|
|
||||||
| "IP"
|
|
||||||
| "COUNTRY"
|
|
||||||
| "COUNTRY_IS_NOT"
|
|
||||||
| "ASN"
|
|
||||||
| "REGION"
|
|
||||||
>()
|
|
||||||
.notNull(), // CIDR, PATH, IP
|
|
||||||
value: text("value").notNull()
|
value: text("value").notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const rolePolicies = sqliteTable("rolePolicies", {
|
|
||||||
roleId: integer("roleId")
|
|
||||||
.notNull()
|
|
||||||
.references(() => roles.roleId, { onDelete: "cascade" }),
|
|
||||||
resourcePolicyId: integer("resourcePolicyId")
|
|
||||||
.notNull()
|
|
||||||
.references(() => resourcePolicies.resourcePolicyId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
export const userPolicies = sqliteTable("userPolicies", {
|
|
||||||
userId: text("userId")
|
|
||||||
.notNull()
|
|
||||||
.references(() => users.userId, { onDelete: "cascade" }),
|
|
||||||
resourcePolicyId: integer("resourcePolicyId")
|
|
||||||
.notNull()
|
|
||||||
.references(() => resourcePolicies.resourcePolicyId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
export const resourcePolicyWhiteList = sqliteTable("resourcePolicyWhitelist", {
|
|
||||||
whitelistId: integer("id").primaryKey({ autoIncrement: true }),
|
|
||||||
email: text("email").notNull(),
|
|
||||||
resourcePolicyId: integer("resourcePolicyId")
|
|
||||||
.notNull()
|
|
||||||
.references(() => resourcePolicies.resourcePolicyId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
export const resourcePolicyRules = sqliteTable("resourcePolicyRules", {
|
|
||||||
ruleId: integer("ruleId").primaryKey({ autoIncrement: true }),
|
|
||||||
resourcePolicyId: integer("resourcePolicyId")
|
|
||||||
.notNull()
|
|
||||||
.references(() => resourcePolicies.resourcePolicyId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
}),
|
|
||||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
|
||||||
priority: integer("priority").notNull(),
|
|
||||||
action: text("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(),
|
|
||||||
match: text("match")
|
|
||||||
.$type<
|
|
||||||
| "CIDR"
|
|
||||||
| "PATH"
|
|
||||||
| "IP"
|
|
||||||
| "COUNTRY"
|
|
||||||
| "COUNTRY_IS_NOT"
|
|
||||||
| "ASN"
|
|
||||||
| "REGION"
|
|
||||||
>()
|
|
||||||
.notNull(),
|
|
||||||
value: text("value").notNull()
|
|
||||||
});
|
|
||||||
|
|
||||||
export const resourcePolicies = sqliteTable("resourcePolicies", {
|
|
||||||
resourcePolicyId: integer("resourcePolicyId").primaryKey(),
|
|
||||||
sso: integer("sso", { mode: "boolean" }).notNull().default(true),
|
|
||||||
applyRules: integer("applyRules", { mode: "boolean" })
|
|
||||||
.notNull()
|
|
||||||
.default(false),
|
|
||||||
scope: text("scope")
|
|
||||||
.$type<"global" | "resource">()
|
|
||||||
.notNull()
|
|
||||||
.default("global"),
|
|
||||||
emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" })
|
|
||||||
.notNull()
|
|
||||||
.default(false),
|
|
||||||
niceId: text("niceId").notNull(),
|
|
||||||
idpId: integer("idpId").references(() => idp.idpId, {
|
|
||||||
onDelete: "set null"
|
|
||||||
}),
|
|
||||||
name: text("name").notNull(),
|
|
||||||
orgId: text("orgId")
|
|
||||||
.references(() => orgs.orgId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull()
|
|
||||||
});
|
|
||||||
|
|
||||||
export const supporterKey = sqliteTable("supporterKey", {
|
export const supporterKey = sqliteTable("supporterKey", {
|
||||||
keyId: integer("keyId").primaryKey({ autoIncrement: true }),
|
keyId: integer("keyId").primaryKey({ autoIncrement: true }),
|
||||||
key: text("key").notNull(),
|
key: text("key").notNull(),
|
||||||
@@ -1500,54 +1106,6 @@ export const apiKeyOrg = sqliteTable("apiKeyOrg", {
|
|||||||
.notNull()
|
.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", {
|
export const idpOrg = sqliteTable("idpOrg", {
|
||||||
idpId: integer("idpId")
|
idpId: integer("idpId")
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -1638,256 +1196,19 @@ export const roundTripMessageTracker = sqliteTable("roundTripMessageTracker", {
|
|||||||
complete: integer("complete", { mode: "boolean" }).notNull().default(false)
|
complete: integer("complete", { mode: "boolean" }).notNull().default(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const statusHistory = sqliteTable(
|
export const statusHistory = sqliteTable("statusHistory", {
|
||||||
"statusHistory",
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
{
|
entityType: text("entityType").notNull(), // "site" | "healthCheck"
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId
|
||||||
entityType: text("entityType").notNull(), // "site" | "healthCheck"
|
|
||||||
entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId
|
|
||||||
orgId: text("orgId")
|
|
||||||
.notNull()
|
|
||||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
|
||||||
status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks
|
|
||||||
timestamp: integer("timestamp").notNull() // unix epoch seconds
|
|
||||||
},
|
|
||||||
(table) => [
|
|
||||||
index("idx_statusHistory_entity").on(
|
|
||||||
table.entityType,
|
|
||||||
table.entityId,
|
|
||||||
table.timestamp
|
|
||||||
),
|
|
||||||
index("idx_statusHistory_org_timestamp").on(
|
|
||||||
table.orgId,
|
|
||||||
table.timestamp
|
|
||||||
)
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const aiProviders = sqliteTable("aiProviders", {
|
|
||||||
providerId: integer("providerId").primaryKey({ autoIncrement: true }),
|
|
||||||
orgId: text("orgId")
|
orgId: text("orgId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||||
name: text("name").notNull(),
|
status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks
|
||||||
type: text("type")
|
timestamp: integer("timestamp").notNull(), // unix epoch seconds
|
||||||
.$type<
|
}, (table) => [
|
||||||
| "openai"
|
index("idx_statusHistory_entity").on(table.entityType, table.entityId, table.timestamp),
|
||||||
| "anthropic"
|
index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp),
|
||||||
| "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()
|
|
||||||
});
|
|
||||||
|
|
||||||
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"
|
|
||||||
}),
|
|
||||||
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)
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const aiUsageRecords = sqliteTable(
|
|
||||||
"aiUsageRecords",
|
|
||||||
{
|
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
||||||
orgId: text("orgId")
|
|
||||||
.notNull()
|
|
||||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
|
||||||
providerId: integer("providerId")
|
|
||||||
.notNull()
|
|
||||||
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
|
|
||||||
resourceId: integer("resourceId").references(
|
|
||||||
() => resources.resourceId,
|
|
||||||
{ onDelete: "cascade" }
|
|
||||||
),
|
|
||||||
siteResourceId: integer("siteResourceId").references(
|
|
||||||
() => siteResources.siteResourceId,
|
|
||||||
{ onDelete: "cascade" }
|
|
||||||
),
|
|
||||||
userId: text("userId").references(() => users.userId, {
|
|
||||||
onDelete: "set null"
|
|
||||||
}),
|
|
||||||
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
|
|
||||||
)
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
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
|
|
||||||
)
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
export type Org = InferSelectModel<typeof orgs>;
|
export type Org = InferSelectModel<typeof orgs>;
|
||||||
export type User = InferSelectModel<typeof users>;
|
export type User = InferSelectModel<typeof users>;
|
||||||
@@ -1940,10 +1261,6 @@ export type Idp = InferSelectModel<typeof idp>;
|
|||||||
export type ApiKey = InferSelectModel<typeof apiKeys>;
|
export type ApiKey = InferSelectModel<typeof apiKeys>;
|
||||||
export type ApiKeyAction = InferSelectModel<typeof apiKeyActions>;
|
export type ApiKeyAction = InferSelectModel<typeof apiKeyActions>;
|
||||||
export type ApiKeyOrg = InferSelectModel<typeof apiKeyOrg>;
|
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 SiteResource = InferSelectModel<typeof siteResources>;
|
||||||
export type Network = InferSelectModel<typeof networks>;
|
export type Network = InferSelectModel<typeof networks>;
|
||||||
export type OrgDomains = InferSelectModel<typeof orgDomains>;
|
export type OrgDomains = InferSelectModel<typeof orgDomains>;
|
||||||
@@ -1961,28 +1278,3 @@ export type RoundTripMessageTracker = InferSelectModel<
|
|||||||
typeof roundTripMessageTracker
|
typeof roundTripMessageTracker
|
||||||
>;
|
>;
|
||||||
export type StatusHistory = InferSelectModel<typeof statusHistory>;
|
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
|
|
||||||
>;
|
|
||||||
export type ResourcePolicyPassword = InferSelectModel<
|
|
||||||
typeof resourcePolicyPassword
|
|
||||||
>;
|
|
||||||
export type ResourcePolicyHeaderAuth = InferSelectModel<
|
|
||||||
typeof resourcePolicyHeaderAuth
|
|
||||||
>;
|
|
||||||
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 ResourceAiProvider = InferSelectModel<typeof resourceAiProviders>;
|
|
||||||
export type SiteResourceAiProvider = InferSelectModel<
|
|
||||||
typeof siteResourceAiProviders
|
|
||||||
>;
|
|
||||||
export type ResourceAiModel = InferSelectModel<typeof resourceAiModels>;
|
|
||||||
export type SiteResourceAiModel = InferSelectModel<typeof siteResourceAiModels>;
|
|
||||||
|
|||||||
@@ -30,14 +30,14 @@ export const NotifyTrialExpiring = ({
|
|||||||
const isLastDay = daysRemaining === 1;
|
const isLastDay = daysRemaining === 1;
|
||||||
|
|
||||||
const previewText = hasEnded
|
const previewText = hasEnded
|
||||||
? `Your cloud trial for ${orgName} has ended.`
|
? `Your trial for ${orgName} has ended.`
|
||||||
: isLastDay
|
: isLastDay
|
||||||
? `Your cloud trial for ${orgName} ends tomorrow.`
|
? `Your trial for ${orgName} ends tomorrow.`
|
||||||
: `Your cloud trial for ${orgName} ends in ${daysRemaining} days.`;
|
: `Your trial for ${orgName} ends in ${daysRemaining} days.`;
|
||||||
|
|
||||||
const heading = hasEnded
|
const heading = hasEnded
|
||||||
? "Your Cloud Trial Ended"
|
? "Your Trial Ended"
|
||||||
: "Your Cloud Trial is Ending Soon";
|
: "Your Trial is Ending Soon";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
@@ -55,7 +55,7 @@ export const NotifyTrialExpiring = ({
|
|||||||
{hasEnded ? (
|
{hasEnded ? (
|
||||||
<>
|
<>
|
||||||
<EmailText>
|
<EmailText>
|
||||||
Your cloud free trial for{" "}
|
Your free trial for{" "}
|
||||||
<strong>{orgName}</strong> ended on{" "}
|
<strong>{orgName}</strong> ended on{" "}
|
||||||
<strong>{trialEndsAt}</strong>. Your account
|
<strong>{trialEndsAt}</strong>. Your account
|
||||||
has been moved to the free plan, which
|
has been moved to the free plan, which
|
||||||
@@ -64,11 +64,10 @@ export const NotifyTrialExpiring = ({
|
|||||||
|
|
||||||
<EmailText>
|
<EmailText>
|
||||||
Some features and resources may now be
|
Some features and resources may now be
|
||||||
restricted. To restore full access and
|
restricted. To restore full
|
||||||
continue using all the features you had
|
access and continue using all the features
|
||||||
during your trial, please upgrade to a paid
|
you had during your trial, please upgrade to
|
||||||
plan. This does not effect any self hosted
|
a paid plan.
|
||||||
licenses.
|
|
||||||
</EmailText>
|
</EmailText>
|
||||||
|
|
||||||
<EmailText>
|
<EmailText>
|
||||||
@@ -94,8 +93,7 @@ export const NotifyTrialExpiring = ({
|
|||||||
<EmailText>
|
<EmailText>
|
||||||
After your trial ends, your account will be
|
After your trial ends, your account will be
|
||||||
moved to the free plan and some
|
moved to the free plan and some
|
||||||
functionality may be restricted. This does
|
functionality may be restricted.
|
||||||
not effect any self hosted licenses.
|
|
||||||
</EmailText>
|
</EmailText>
|
||||||
|
|
||||||
<EmailText>
|
<EmailText>
|
||||||
|
|||||||
+2
-17
@@ -1,24 +1,19 @@
|
|||||||
#! /usr/bin/env node
|
#! /usr/bin/env node
|
||||||
import "./extendZod";
|
import "./extendZod.ts";
|
||||||
|
|
||||||
import { runSetupFunctions } from "./setup";
|
import { runSetupFunctions } from "./setup";
|
||||||
import { createApiServer } from "./apiServer";
|
import { createApiServer } from "./apiServer";
|
||||||
import { createNextServer } from "./nextServer";
|
import { createNextServer } from "./nextServer";
|
||||||
import { createInternalServer } from "./internalServer";
|
import { createInternalServer } from "./internalServer";
|
||||||
import { createAiGatewayServer } from "./aiGatewayServer";
|
|
||||||
import { createIntegrationApiServer } from "./integrationApiServer";
|
import { createIntegrationApiServer } from "./integrationApiServer";
|
||||||
import {
|
import {
|
||||||
ApiKey,
|
ApiKey,
|
||||||
ApiKeyOrg,
|
ApiKeyOrg,
|
||||||
AiBudget,
|
|
||||||
AiModel,
|
|
||||||
AiProvider,
|
|
||||||
RemoteExitNode,
|
RemoteExitNode,
|
||||||
Session,
|
Session,
|
||||||
SiteResource,
|
SiteResource,
|
||||||
User,
|
User,
|
||||||
UserOrg,
|
UserOrg
|
||||||
VirtualApiKey
|
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { setHostMeta } from "@server/lib/hostMeta";
|
import { setHostMeta } from "@server/lib/hostMeta";
|
||||||
@@ -29,8 +24,6 @@ import license from "#dynamic/license/license";
|
|||||||
import { initLogCleanupInterval } from "@server/lib/cleanupLogs";
|
import { initLogCleanupInterval } from "@server/lib/cleanupLogs";
|
||||||
import { initAcmeCertSync } from "#dynamic/lib/acmeCertSync";
|
import { initAcmeCertSync } from "#dynamic/lib/acmeCertSync";
|
||||||
import { fetchServerIp } from "@server/lib/serverIpService";
|
import { fetchServerIp } from "@server/lib/serverIpService";
|
||||||
import { startRebuildQueueProcessor } from "@server/lib/rebuildClientAssociations";
|
|
||||||
import { initAiModelCatalog } from "@server/lib/aiModelCatalog";
|
|
||||||
|
|
||||||
async function startServers() {
|
async function startServers() {
|
||||||
await setHostMeta();
|
await setHostMeta();
|
||||||
@@ -48,13 +41,10 @@ async function startServers() {
|
|||||||
|
|
||||||
initLogCleanupInterval();
|
initLogCleanupInterval();
|
||||||
initAcmeCertSync();
|
initAcmeCertSync();
|
||||||
startRebuildQueueProcessor();
|
|
||||||
await initAiModelCatalog();
|
|
||||||
|
|
||||||
// Start all servers
|
// Start all servers
|
||||||
const apiServer = createApiServer();
|
const apiServer = createApiServer();
|
||||||
const internalServer = createInternalServer();
|
const internalServer = createInternalServer();
|
||||||
const aiGatewayServer = createAiGatewayServer();
|
|
||||||
|
|
||||||
const nextServer = await createNextServer();
|
const nextServer = await createNextServer();
|
||||||
if (config.getRawConfig().traefik.file_mode) {
|
if (config.getRawConfig().traefik.file_mode) {
|
||||||
@@ -73,7 +63,6 @@ async function startServers() {
|
|||||||
apiServer,
|
apiServer,
|
||||||
nextServer,
|
nextServer,
|
||||||
internalServer,
|
internalServer,
|
||||||
aiGatewayServer,
|
|
||||||
integrationServer
|
integrationServer
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -92,10 +81,6 @@ declare global {
|
|||||||
userOrgIds?: string[];
|
userOrgIds?: string[];
|
||||||
remoteExitNode?: RemoteExitNode;
|
remoteExitNode?: RemoteExitNode;
|
||||||
siteResource?: SiteResource;
|
siteResource?: SiteResource;
|
||||||
aiProvider?: AiProvider;
|
|
||||||
aiModel?: AiModel;
|
|
||||||
aiBudget?: AiBudget;
|
|
||||||
virtualApiKey?: VirtualApiKey;
|
|
||||||
orgPolicyAllowed?: boolean;
|
orgPolicyAllowed?: boolean;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { logIncomingMiddleware } from "./middlewares/logIncoming";
|
|||||||
import helmet from "helmet";
|
import helmet from "helmet";
|
||||||
import swaggerUi from "swagger-ui-express";
|
import swaggerUi from "swagger-ui-express";
|
||||||
import { OpenApiGeneratorV3 } from "@asteasolutions/zod-to-openapi";
|
import { OpenApiGeneratorV3 } from "@asteasolutions/zod-to-openapi";
|
||||||
import { registry, openApiTags } from "./openApi";
|
import { registry } from "./openApi";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { APP_PATH } from "./lib/consts";
|
import { APP_PATH } from "./lib/consts";
|
||||||
@@ -152,19 +152,11 @@ function getOpenApiDocumentation() {
|
|||||||
|
|
||||||
if (!hasExistingResponses) {
|
if (!hasExistingResponses) {
|
||||||
def.route.responses = {
|
def.route.responses = {
|
||||||
"200": {
|
"*": {
|
||||||
description: "Successful response",
|
description: "",
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
schema: z.object({
|
schema: z.object({})
|
||||||
data: z
|
|
||||||
.record(z.string(), z.any())
|
|
||||||
.nullable(),
|
|
||||||
success: z.boolean(),
|
|
||||||
error: z.boolean(),
|
|
||||||
message: z.string(),
|
|
||||||
status: z.number()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,8 +173,7 @@ function getOpenApiDocumentation() {
|
|||||||
version: "v1",
|
version: "v1",
|
||||||
title: "Pangolin Integration API"
|
title: "Pangolin Integration API"
|
||||||
},
|
},
|
||||||
servers: [{ url: "/v1" }],
|
servers: [{ url: "/v1" }]
|
||||||
tags: openApiTags
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!process.env.DISABLE_GEN_OPENAPI) {
|
if (!process.env.DISABLE_GEN_OPENAPI) {
|
||||||
|
|||||||
@@ -1,463 +0,0 @@
|
|||||||
import { and, eq, gte, inArray, isNull, or, sql, SQL } 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
|
|
||||||
].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;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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));
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
requestedModel: string;
|
|
||||||
usage: AiUsage;
|
|
||||||
costUsd: number | null;
|
|
||||||
createdAt?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function recordUsage(input: UsageRecordInput): Promise<void> {
|
|
||||||
try {
|
|
||||||
const { usage } = input;
|
|
||||||
const totalTokens =
|
|
||||||
usage.promptTokens +
|
|
||||||
usage.cacheReadTokens +
|
|
||||||
usage.cacheWriteTokens +
|
|
||||||
usage.completionTokens +
|
|
||||||
usage.reasoningTokens;
|
|
||||||
|
|
||||||
await db.insert(aiUsageRecords).values({
|
|
||||||
orgId: input.orgId,
|
|
||||||
providerId: input.providerId,
|
|
||||||
resourceId: input.resourceId,
|
|
||||||
siteResourceId: input.siteResourceId,
|
|
||||||
userId: input.userId,
|
|
||||||
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()
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Failed to record AI usage", { error });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,283 +0,0 @@
|
|||||||
import type { Request } from "express";
|
|
||||||
|
|
||||||
export const AI_CAPABILITIES = [
|
|
||||||
"openai_chat",
|
|
||||||
"openai_responses",
|
|
||||||
"anthropic_messages",
|
|
||||||
"gemini_generate_content",
|
|
||||||
"bedrock_model_invoke",
|
|
||||||
"google_generate_content",
|
|
||||||
"google_raw_predict",
|
|
||||||
"bedrock_converse"
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export type AiCapability = (typeof AI_CAPABILITIES)[number];
|
|
||||||
|
|
||||||
export type AiCapabilityRoute = {
|
|
||||||
method: "POST";
|
|
||||||
path: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AiCapabilityDefinition = {
|
|
||||||
id: AiCapability;
|
|
||||||
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",
|
|
||||||
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",
|
|
||||||
routes: [{ method: "POST", path: "/v1/responses" }],
|
|
||||||
extractModel: bodyModel,
|
|
||||||
resolveUpstreamUrl: (base, req) =>
|
|
||||||
joinUpstreamUrl(base, pathFromRequest(req)),
|
|
||||||
isStreaming: isBodyOrSseStreaming
|
|
||||||
},
|
|
||||||
anthropic_messages: {
|
|
||||||
id: "anthropic_messages",
|
|
||||||
routes: [{ method: "POST", path: "/v1/messages" }],
|
|
||||||
extractModel: bodyModel,
|
|
||||||
resolveUpstreamUrl: (base, req) =>
|
|
||||||
joinUpstreamUrl(base, pathFromRequest(req)),
|
|
||||||
isStreaming: isBodyOrSseStreaming
|
|
||||||
},
|
|
||||||
gemini_generate_content: {
|
|
||||||
id: "gemini_generate_content",
|
|
||||||
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",
|
|
||||||
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",
|
|
||||||
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",
|
|
||||||
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",
|
|
||||||
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)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,734 +0,0 @@
|
|||||||
import { and, eq, inArray } from "drizzle-orm";
|
|
||||||
import {
|
|
||||||
aiModels,
|
|
||||||
aiProviders,
|
|
||||||
db,
|
|
||||||
resourceAiModels,
|
|
||||||
resourceAiProviders,
|
|
||||||
siteResourceAiModels,
|
|
||||||
siteResourceAiProviders,
|
|
||||||
type Transaction
|
|
||||||
} from "@server/db";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { modelKeysConflict } from "@server/lib/aiModelKeyMatch";
|
|
||||||
|
|
||||||
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
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type EffectiveAllowRow = {
|
|
||||||
providerId: number;
|
|
||||||
modelKey: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure effective allow modelKeys do not conflict across attached providers.
|
|
||||||
* inherit uses provider allows; select uses resource-selected allows (or the
|
|
||||||
* optional override map). Block patterns are ignored for overlap checks.
|
|
||||||
*/
|
|
||||||
export async function assertNoOverlappingModelKeys(
|
|
||||||
attachments: ResourceAiProviderAttachment[],
|
|
||||||
options: {
|
|
||||||
trx?: DbOrTrx;
|
|
||||||
resourceId?: number;
|
|
||||||
siteResourceId?: number;
|
|
||||||
selectedAllowsByProvider?: Map<number, string[]>;
|
|
||||||
} = {}
|
|
||||||
): Promise<InferenceFieldsError | null> {
|
|
||||||
const trx = options.trx ?? db;
|
|
||||||
|
|
||||||
const activeAttachments = attachments.filter((a) => a.enabled);
|
|
||||||
|
|
||||||
if (activeAttachments.length < 2) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 effectiveAllows: EffectiveAllowRow[] = [];
|
|
||||||
|
|
||||||
if (inheritProviderIds.length > 0) {
|
|
||||||
const providerAllows = await trx
|
|
||||||
.select({
|
|
||||||
providerId: aiModels.providerId,
|
|
||||||
modelKey: aiModels.modelKey
|
|
||||||
})
|
|
||||||
.from(aiModels)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
inArray(aiModels.providerId, inheritProviderIds),
|
|
||||||
eq(aiModels.enabled, true),
|
|
||||||
eq(aiModels.listType, "allow")
|
|
||||||
)
|
|
||||||
);
|
|
||||||
effectiveAllows.push(...providerAllows);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectProviderIds.length > 0) {
|
|
||||||
if (options.selectedAllowsByProvider) {
|
|
||||||
for (const providerId of selectProviderIds) {
|
|
||||||
const keys =
|
|
||||||
options.selectedAllowsByProvider.get(providerId) ?? [];
|
|
||||||
for (const modelKey of keys) {
|
|
||||||
effectiveAllows.push({ providerId, modelKey });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (options.resourceId !== undefined) {
|
|
||||||
const rows = await trx
|
|
||||||
.select({
|
|
||||||
providerId: aiModels.providerId,
|
|
||||||
modelKey: aiModels.modelKey
|
|
||||||
})
|
|
||||||
.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)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
effectiveAllows.push(...rows);
|
|
||||||
} else if (options.siteResourceId !== undefined) {
|
|
||||||
const rows = await trx
|
|
||||||
.select({
|
|
||||||
providerId: aiModels.providerId,
|
|
||||||
modelKey: aiModels.modelKey
|
|
||||||
})
|
|
||||||
.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)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
effectiveAllows.push(...rows);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const conflictPairs: string[] = [];
|
|
||||||
for (let i = 0; i < effectiveAllows.length; i++) {
|
|
||||||
for (let j = i + 1; j < effectiveAllows.length; j++) {
|
|
||||||
const left = effectiveAllows[i];
|
|
||||||
const right = effectiveAllows[j];
|
|
||||||
if (left.providerId === right.providerId) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!modelKeysConflict(left.modelKey, right.modelKey)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const pair = [left.modelKey, right.modelKey].sort().join(" vs ");
|
|
||||||
if (!conflictPairs.includes(pair)) {
|
|
||||||
conflictPairs.push(pair);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (conflictPairs.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
conflictPairs.sort();
|
|
||||||
return {
|
|
||||||
error: `Model keys must be unique across providers on a resource. Overlapping keys: ${conflictPairs.join(", ")}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate provider attachments for an org.
|
|
||||||
*/
|
|
||||||
export async function resolveProviderAttachments(input: {
|
|
||||||
orgId: string;
|
|
||||||
attachments: ResourceAiProviderInput[];
|
|
||||||
requireAtLeastOne: boolean;
|
|
||||||
resourceId?: number;
|
|
||||||
siteResourceId?: number;
|
|
||||||
}): 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`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const overlapError = await assertNoOverlappingModelKeys(attachments, {
|
|
||||||
resourceId: input.resourceId,
|
|
||||||
siteResourceId: input.siteResourceId
|
|
||||||
});
|
|
||||||
if (overlapError) {
|
|
||||||
return overlapError;
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
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,
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 (allow→allow, block→block).
|
|
||||||
*/
|
|
||||||
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,
|
|
||||||
modelKey: aiModels.modelKey,
|
|
||||||
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]));
|
|
||||||
const selectedAllowsByProvider = new Map<number, string[]>();
|
|
||||||
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`;
|
|
||||||
}
|
|
||||||
if (entry.listType === "allow") {
|
|
||||||
const keys = selectedAllowsByProvider.get(catalog.providerId) ?? [];
|
|
||||||
keys.push(catalog.modelKey);
|
|
||||||
selectedAllowsByProvider.set(catalog.providerId, keys);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const overlapError = await assertNoOverlappingModelKeys(input.attachments, {
|
|
||||||
selectedAllowsByProvider
|
|
||||||
});
|
|
||||||
if (overlapError) {
|
|
||||||
return overlapError.error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,275 +0,0 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
import axios from "axios";
|
|
||||||
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: {
|
|
||||||
input: number | null;
|
|
||||||
output: number | null;
|
|
||||||
cacheRead: number | null;
|
|
||||||
reasoningOutput: number | null;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type RawCatalogEntry = {
|
|
||||||
id?: string;
|
|
||||||
name?: string;
|
|
||||||
model?: string;
|
|
||||||
provider: string;
|
|
||||||
input_cost_per_token?: number | null;
|
|
||||||
output_cost_per_token?: number | null;
|
|
||||||
cache_read_input_token_cost?: number | null;
|
|
||||||
output_cost_per_reasoning_token?: number | null;
|
|
||||||
pricing?: {
|
|
||||||
input?: number | null;
|
|
||||||
output?: number | null;
|
|
||||||
cacheRead?: number | null;
|
|
||||||
reasoningOutput?: number | null;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const model = raw.model ?? raw.name ?? raw.id;
|
|
||||||
if (!model) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
provider,
|
|
||||||
model,
|
|
||||||
pricing: {
|
|
||||||
input: raw.pricing?.input ?? raw.input_cost_per_token ?? null,
|
|
||||||
output: raw.pricing?.output ?? raw.output_cost_per_token ?? null,
|
|
||||||
cacheRead:
|
|
||||||
raw.pricing?.cacheRead ??
|
|
||||||
raw.cache_read_input_token_cost ??
|
|
||||||
null,
|
|
||||||
reasoningOutput:
|
|
||||||
raw.pricing?.reasoningOutput ??
|
|
||||||
raw.output_cost_per_reasoning_token ??
|
|
||||||
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 parsed = JSON.parse(raw) as { data: RawCatalogEntry[] };
|
|
||||||
return (parsed.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<{ data: RawCatalogEntry[] }>(
|
|
||||||
upstreamUrl,
|
|
||||||
{ timeout: 15_000 }
|
|
||||||
);
|
|
||||||
return (res.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, upstream_url } = config.getRawConfig().ai.model_catalog;
|
|
||||||
|
|
||||||
const fetched = file
|
|
||||||
? await this.fetchFromFile(file)
|
|
||||||
: await this.fetchFromUpstream(upstream_url);
|
|
||||||
|
|
||||||
if (fetched) {
|
|
||||||
this.setEntries(fetched);
|
|
||||||
logger.debug(
|
|
||||||
`AI model catalog refreshed: ${this.entries.length} models loaded`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
logger.debug(
|
|
||||||
"AI model catalog refresh failed; keeping previously loaded catalog in memory"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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();
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attach-time conflict check. Detects identical keys and exact-vs-pattern
|
|
||||||
* matches. Does not attempt full glob intersection.
|
|
||||||
*/
|
|
||||||
export function modelKeysConflict(a: string, b: string): boolean {
|
|
||||||
if (a === b) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const aIsPattern = isModelKeyPattern(a);
|
|
||||||
const bIsPattern = isModelKeyPattern(b);
|
|
||||||
|
|
||||||
if (aIsPattern === bIsPattern) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (aIsPattern) {
|
|
||||||
return modelKeyMatches(a, b);
|
|
||||||
}
|
|
||||||
|
|
||||||
return modelKeyMatches(b, 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];
|
|
||||||
}
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
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.input,
|
|
||||||
outputCostPerToken: entry.pricing.output,
|
|
||||||
cacheReadInputTokenCost: entry.pricing.cacheRead,
|
|
||||||
outputCostPerReasoningToken: entry.pricing.reasoningOutput,
|
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
import { decrypt, encrypt } from "@server/lib/crypto";
|
|
||||||
import {
|
|
||||||
parseCapabilities,
|
|
||||||
type AiCapability
|
|
||||||
} from "@server/lib/aiCapabilities";
|
|
||||||
|
|
||||||
export type AiProviderType =
|
|
||||||
| "openai"
|
|
||||||
| "anthropic"
|
|
||||||
| "googleGemini"
|
|
||||||
| "vertexAi"
|
|
||||||
| "bedrock"
|
|
||||||
| "microsoftFoundry"
|
|
||||||
| "openRouter"
|
|
||||||
| "vercelAiGateway"
|
|
||||||
| "custom";
|
|
||||||
|
|
||||||
export const AI_PROVIDER_AUTH_TYPES = [
|
|
||||||
"bearer",
|
|
||||||
"x-api-key",
|
|
||||||
"x-goog-api-key",
|
|
||||||
"hec",
|
|
||||||
"cf-aig-authorization",
|
|
||||||
"none",
|
|
||||||
"passthrough"
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export type AiProviderAuthType = (typeof AI_PROVIDER_AUTH_TYPES)[number];
|
|
||||||
export type AiBudgetUnit = "usd" | "tokens";
|
|
||||||
export type AiProviderRoutingMode = "url" | "target";
|
|
||||||
|
|
||||||
type AiProviderDefaults = {
|
|
||||||
upstreamUrl: string | null;
|
|
||||||
authType: AiProviderAuthType;
|
|
||||||
capabilities: readonly AiCapability[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AI_PROVIDER_DEFAULTS: Record<
|
|
||||||
Exclude<AiProviderType, "custom">,
|
|
||||||
AiProviderDefaults
|
|
||||||
> = {
|
|
||||||
openai: {
|
|
||||||
upstreamUrl: "https://api.openai.com/v1",
|
|
||||||
authType: "bearer",
|
|
||||||
capabilities: ["openai_chat", "openai_responses"]
|
|
||||||
},
|
|
||||||
anthropic: {
|
|
||||||
upstreamUrl: "https://api.anthropic.com",
|
|
||||||
authType: "x-api-key",
|
|
||||||
capabilities: ["anthropic_messages"]
|
|
||||||
},
|
|
||||||
googleGemini: {
|
|
||||||
upstreamUrl: "https://generativelanguage.googleapis.com",
|
|
||||||
authType: "x-goog-api-key",
|
|
||||||
capabilities: ["gemini_generate_content"]
|
|
||||||
},
|
|
||||||
vertexAi: {
|
|
||||||
upstreamUrl: null,
|
|
||||||
authType: "bearer",
|
|
||||||
capabilities: ["google_generate_content", "google_raw_predict"]
|
|
||||||
},
|
|
||||||
bedrock: {
|
|
||||||
upstreamUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
|
|
||||||
authType: "bearer",
|
|
||||||
capabilities: ["bedrock_converse"]
|
|
||||||
},
|
|
||||||
microsoftFoundry: {
|
|
||||||
upstreamUrl: null,
|
|
||||||
authType: "bearer",
|
|
||||||
capabilities: ["openai_chat", "openai_responses", "anthropic_messages"]
|
|
||||||
},
|
|
||||||
openRouter: {
|
|
||||||
upstreamUrl: "https://openrouter.ai/api/v1",
|
|
||||||
authType: "bearer",
|
|
||||||
capabilities: ["openai_chat"]
|
|
||||||
},
|
|
||||||
vercelAiGateway: {
|
|
||||||
upstreamUrl: "https://ai-gateway.vercel.sh/v1",
|
|
||||||
authType: "bearer",
|
|
||||||
capabilities: ["openai_chat", "openai_responses"]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const CONFLICTING_AUTH_HEADERS = [
|
|
||||||
"authorization",
|
|
||||||
"x-api-key",
|
|
||||||
"x-goog-api-key",
|
|
||||||
"cf-aig-authorization"
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export function authTypeRequiresApiKey(authType: AiProviderAuthType): boolean {
|
|
||||||
return authType !== "none" && authType !== "passthrough";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function providerRequiresUpstreamUrl(
|
|
||||||
type: AiProviderType,
|
|
||||||
routingMode: AiProviderRoutingMode = "url"
|
|
||||||
): boolean {
|
|
||||||
if (routingMode === "target") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (type === "custom") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return AI_PROVIDER_DEFAULTS[type].upstreamUrl === null;
|
|
||||||
}
|
|
||||||
|
|
||||||
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.
|
|
||||||
* - Injected modes: strip client auth headers, then set the provider key.
|
|
||||||
* - none: strip client auth headers, send no auth.
|
|
||||||
* - passthrough: leave client auth headers as-is.
|
|
||||||
*/
|
|
||||||
export function applyAiProviderAuthHeaders(
|
|
||||||
headers: Record<string, string>,
|
|
||||||
authType: AiProviderAuthType,
|
|
||||||
apiKey: string | null
|
|
||||||
): void {
|
|
||||||
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];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function defaultsForProviderType(
|
|
||||||
type: AiProviderType
|
|
||||||
): readonly AiCapability[] {
|
|
||||||
if (type === "custom") {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return AI_PROVIDER_DEFAULTS[type].capabilities;
|
|
||||||
}
|
|
||||||
@@ -1,468 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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,10 +202,6 @@ async function handleResource(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!target.resourceId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [resource] = await trx
|
const [resource] = await trx
|
||||||
.select()
|
.select()
|
||||||
.from(resources)
|
.from(resources)
|
||||||
@@ -225,16 +221,10 @@ async function handleResource(
|
|||||||
)
|
)
|
||||||
.where(eq(targets.resourceId, resource.resourceId));
|
.where(eq(targets.resourceId, resource.resourceId));
|
||||||
|
|
||||||
const monitoredTargets = otherTargets.filter(
|
|
||||||
(t) => t.hcHealth !== "unknown"
|
|
||||||
);
|
|
||||||
|
|
||||||
let health = "healthy";
|
let health = "healthy";
|
||||||
const allUnknown = monitoredTargets.length === 0;
|
const allUnknown = otherTargets.every((t) => t.hcHealth === "unknown");
|
||||||
const allHealthy = monitoredTargets.every((t) => t.hcHealth === "healthy");
|
const allHealthy = otherTargets.every((t) => t.hcHealth === "healthy");
|
||||||
const allUnhealthy = monitoredTargets.every(
|
const allUnhealthy = otherTargets.every((t) => t.hcHealth === "unhealthy");
|
||||||
(t) => t.hcHealth === "unhealthy"
|
|
||||||
);
|
|
||||||
|
|
||||||
if (allUnknown) {
|
if (allUnknown) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
|||||||
@@ -1,39 +1,28 @@
|
|||||||
export enum LimitId {
|
export enum FeatureId {
|
||||||
USERS = "users",
|
USERS = "users",
|
||||||
SITES = "sites",
|
SITES = "sites",
|
||||||
EGRESS_DATA_MB = "egressDataMb",
|
EGRESS_DATA_MB = "egressDataMb",
|
||||||
DOMAINS = "domains",
|
DOMAINS = "domains",
|
||||||
REMOTE_EXIT_NODES = "remoteExitNodes",
|
REMOTE_EXIT_NODES = "remoteExitNodes",
|
||||||
ORGANIZATIONS = "organizations",
|
ORGINIZATIONS = "organizations",
|
||||||
PUBLIC_RESOURCES = "publicResources",
|
|
||||||
PRIVATE_RESOURCES = "privateResources",
|
|
||||||
MACHINE_CLIENTS = "machineClients",
|
|
||||||
TIER1 = "tier1"
|
TIER1 = "tier1"
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getFeatureDisplayName(
|
export async function getFeatureDisplayName(featureId: FeatureId): Promise<string> {
|
||||||
featureId: LimitId
|
|
||||||
): Promise<string> {
|
|
||||||
switch (featureId) {
|
switch (featureId) {
|
||||||
case LimitId.USERS:
|
case FeatureId.USERS:
|
||||||
return "Users";
|
return "Users";
|
||||||
case LimitId.SITES:
|
case FeatureId.SITES:
|
||||||
return "Sites";
|
return "Sites";
|
||||||
case LimitId.EGRESS_DATA_MB:
|
case FeatureId.EGRESS_DATA_MB:
|
||||||
return "Egress Data (MB)";
|
return "Egress Data (MB)";
|
||||||
case LimitId.DOMAINS:
|
case FeatureId.DOMAINS:
|
||||||
return "Domains";
|
return "Domains";
|
||||||
case LimitId.REMOTE_EXIT_NODES:
|
case FeatureId.REMOTE_EXIT_NODES:
|
||||||
return "Remote Exit Nodes";
|
return "Remote Exit Nodes";
|
||||||
case LimitId.ORGANIZATIONS:
|
case FeatureId.ORGINIZATIONS:
|
||||||
return "Organizations";
|
return "Organizations";
|
||||||
case LimitId.PUBLIC_RESOURCES:
|
case FeatureId.TIER1:
|
||||||
return "Public Resources";
|
|
||||||
case LimitId.PRIVATE_RESOURCES:
|
|
||||||
return "Private Resources";
|
|
||||||
case LimitId.MACHINE_CLIENTS:
|
|
||||||
return "Machine Clients";
|
|
||||||
case LimitId.TIER1:
|
|
||||||
return "Home Lab";
|
return "Home Lab";
|
||||||
default:
|
default:
|
||||||
return featureId;
|
return featureId;
|
||||||
@@ -41,16 +30,15 @@ export async function getFeatureDisplayName(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// this is from the old system
|
// this is from the old system
|
||||||
export const FeatureMeterIds: Partial<Record<LimitId, string>> = {
|
export const FeatureMeterIds: Partial<Record<FeatureId, string>> = { // right now we are not charging for any data
|
||||||
// right now we are not charging for any data
|
|
||||||
// [FeatureId.EGRESS_DATA_MB]: "mtr_61Srreh9eWrExDSCe41D3Ee2Ir7Wm5YW"
|
// [FeatureId.EGRESS_DATA_MB]: "mtr_61Srreh9eWrExDSCe41D3Ee2Ir7Wm5YW"
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FeatureMeterIdsSandbox: Partial<Record<LimitId, string>> = {
|
export const FeatureMeterIdsSandbox: Partial<Record<FeatureId, string>> = {
|
||||||
// [FeatureId.EGRESS_DATA_MB]: "mtr_test_61Snh2a2m6qome5Kv41DCpkOb237B3dQ"
|
// [FeatureId.EGRESS_DATA_MB]: "mtr_test_61Snh2a2m6qome5Kv41DCpkOb237B3dQ"
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getFeatureMeterId(featureId: LimitId): string | undefined {
|
export function getFeatureMeterId(featureId: FeatureId): string | undefined {
|
||||||
if (
|
if (
|
||||||
process.env.ENVIRONMENT == "prod" &&
|
process.env.ENVIRONMENT == "prod" &&
|
||||||
process.env.SANDBOX_MODE !== "true"
|
process.env.SANDBOX_MODE !== "true"
|
||||||
@@ -61,20 +49,22 @@ export function getFeatureMeterId(featureId: LimitId): string | undefined {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFeatureIdByMetricId(metricId: string): LimitId | undefined {
|
export function getFeatureIdByMetricId(
|
||||||
return (Object.entries(FeatureMeterIds) as [LimitId, string][]).find(
|
metricId: string
|
||||||
|
): FeatureId | undefined {
|
||||||
|
return (Object.entries(FeatureMeterIds) as [FeatureId, string][]).find(
|
||||||
([_, v]) => v === metricId
|
([_, v]) => v === metricId
|
||||||
)?.[0];
|
)?.[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FeaturePriceSet = Partial<Record<LimitId, string>>;
|
export type FeaturePriceSet = Partial<Record<FeatureId, string>>;
|
||||||
|
|
||||||
export const tier1FeaturePriceSet: FeaturePriceSet = {
|
export const tier1FeaturePriceSet: FeaturePriceSet = {
|
||||||
[LimitId.TIER1]: "price_1SzVE3D3Ee2Ir7Wm6wT5Dl3G"
|
[FeatureId.TIER1]: "price_1SzVE3D3Ee2Ir7Wm6wT5Dl3G"
|
||||||
};
|
};
|
||||||
|
|
||||||
export const tier1FeaturePriceSetSandbox: FeaturePriceSet = {
|
export const tier1FeaturePriceSetSandbox: FeaturePriceSet = {
|
||||||
[LimitId.TIER1]: "price_1SxgpPDCpkOb237Bfo4rIsoT"
|
[FeatureId.TIER1]: "price_1SxgpPDCpkOb237Bfo4rIsoT"
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getTier1FeaturePriceSet(): FeaturePriceSet {
|
export function getTier1FeaturePriceSet(): FeaturePriceSet {
|
||||||
@@ -89,11 +79,11 @@ export function getTier1FeaturePriceSet(): FeaturePriceSet {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const tier2FeaturePriceSet: FeaturePriceSet = {
|
export const tier2FeaturePriceSet: FeaturePriceSet = {
|
||||||
[LimitId.USERS]: "price_1SzVCcD3Ee2Ir7Wmn6U3KvPN"
|
[FeatureId.USERS]: "price_1SzVCcD3Ee2Ir7Wmn6U3KvPN"
|
||||||
};
|
};
|
||||||
|
|
||||||
export const tier2FeaturePriceSetSandbox: FeaturePriceSet = {
|
export const tier2FeaturePriceSetSandbox: FeaturePriceSet = {
|
||||||
[LimitId.USERS]: "price_1SxaEHDCpkOb237BD9lBkPiR"
|
[FeatureId.USERS]: "price_1SxaEHDCpkOb237BD9lBkPiR"
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getTier2FeaturePriceSet(): FeaturePriceSet {
|
export function getTier2FeaturePriceSet(): FeaturePriceSet {
|
||||||
@@ -108,11 +98,11 @@ export function getTier2FeaturePriceSet(): FeaturePriceSet {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const tier3FeaturePriceSet: FeaturePriceSet = {
|
export const tier3FeaturePriceSet: FeaturePriceSet = {
|
||||||
[LimitId.USERS]: "price_1SzVDKD3Ee2Ir7WmPtOKNusv"
|
[FeatureId.USERS]: "price_1SzVDKD3Ee2Ir7WmPtOKNusv"
|
||||||
};
|
};
|
||||||
|
|
||||||
export const tier3FeaturePriceSetSandbox: FeaturePriceSet = {
|
export const tier3FeaturePriceSetSandbox: FeaturePriceSet = {
|
||||||
[LimitId.USERS]: "price_1SxaEODCpkOb237BiXdCBSfs"
|
[FeatureId.USERS]: "price_1SxaEODCpkOb237BiXdCBSfs"
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getTier3FeaturePriceSet(): FeaturePriceSet {
|
export function getTier3FeaturePriceSet(): FeaturePriceSet {
|
||||||
@@ -126,7 +116,7 @@ export function getTier3FeaturePriceSet(): FeaturePriceSet {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFeatureIdByPriceId(priceId: string): LimitId | undefined {
|
export function getFeatureIdByPriceId(priceId: string): FeatureId | undefined {
|
||||||
// Check all feature price sets
|
// Check all feature price sets
|
||||||
const allPriceSets = [
|
const allPriceSets = [
|
||||||
getTier1FeaturePriceSet(),
|
getTier1FeaturePriceSet(),
|
||||||
@@ -135,7 +125,7 @@ export function getFeatureIdByPriceId(priceId: string): LimitId | undefined {
|
|||||||
];
|
];
|
||||||
|
|
||||||
for (const priceSet of allPriceSets) {
|
for (const priceSet of allPriceSets) {
|
||||||
const entry = (Object.entries(priceSet) as [LimitId, string][]).find(
|
const entry = (Object.entries(priceSet) as [FeatureId, string][]).find(
|
||||||
([_, price]) => price === priceId
|
([_, price]) => price === priceId
|
||||||
);
|
);
|
||||||
if (entry) {
|
if (entry) {
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
import { LimitId, FeaturePriceSet } from "./features";
|
import { FeatureId, FeaturePriceSet } from "./features";
|
||||||
import { usageService } from "./usageService";
|
import { usageService } from "./usageService";
|
||||||
|
|
||||||
export async function getLineItems(
|
export async function getLineItems(
|
||||||
featurePriceSet: FeaturePriceSet,
|
featurePriceSet: FeaturePriceSet,
|
||||||
orgId: string
|
orgId: string,
|
||||||
): Promise<Stripe.Checkout.SessionCreateParams.LineItem[]> {
|
): Promise<Stripe.Checkout.SessionCreateParams.LineItem[]> {
|
||||||
const users = await usageService.getUsage(orgId, LimitId.USERS);
|
const users = await usageService.getUsage(orgId, FeatureId.USERS);
|
||||||
|
|
||||||
return Object.entries(featurePriceSet).map(([featureId, priceId]) => {
|
return Object.entries(featurePriceSet).map(([featureId, priceId]) => {
|
||||||
let quantity: number | undefined;
|
let quantity: number | undefined;
|
||||||
|
|
||||||
if (featureId === LimitId.USERS) {
|
if (featureId === FeatureId.USERS) {
|
||||||
quantity = users?.instantaneousValue || 1;
|
quantity = users?.instantaneousValue || 1;
|
||||||
} else if (featureId === LimitId.TIER1) {
|
} else if (featureId === FeatureId.TIER1) {
|
||||||
quantity = 1;
|
quantity = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,82 +1,70 @@
|
|||||||
import { LimitId } from "./features";
|
import { FeatureId } from "./features";
|
||||||
|
|
||||||
export type LimitSet = Partial<{
|
export type LimitSet = Partial<{
|
||||||
[key in LimitId]: {
|
[key in FeatureId]: {
|
||||||
value: number | null; // null indicates no limit
|
value: number | null; // null indicates no limit
|
||||||
description?: string;
|
description?: string;
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export const freeLimitSet: LimitSet = {
|
export const freeLimitSet: LimitSet = {
|
||||||
[LimitId.SITES]: { value: 5, description: "Basic limit" },
|
[FeatureId.SITES]: { value: 5, description: "Basic limit" },
|
||||||
[LimitId.USERS]: { value: 5, description: "Basic limit" },
|
[FeatureId.USERS]: { value: 5, description: "Basic limit" },
|
||||||
[LimitId.DOMAINS]: { value: 5, description: "Basic limit" },
|
[FeatureId.DOMAINS]: { value: 5, description: "Basic limit" },
|
||||||
[LimitId.REMOTE_EXIT_NODES]: { value: 1, description: "Basic limit" },
|
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Basic limit" },
|
||||||
[LimitId.ORGANIZATIONS]: { value: 1, description: "Basic limit" },
|
[FeatureId.ORGINIZATIONS]: { 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 = {
|
export const tier1LimitSet: LimitSet = {
|
||||||
[LimitId.USERS]: { value: 7, description: "Home limit" },
|
[FeatureId.USERS]: { value: 7, description: "Home limit" },
|
||||||
[LimitId.SITES]: { value: 10, description: "Home limit" },
|
[FeatureId.SITES]: { value: 10, description: "Home limit" },
|
||||||
[LimitId.DOMAINS]: { value: 10, description: "Home limit" },
|
[FeatureId.DOMAINS]: { value: 10, description: "Home limit" },
|
||||||
[LimitId.REMOTE_EXIT_NODES]: { value: 1, description: "Home limit" },
|
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Home limit" },
|
||||||
[LimitId.ORGANIZATIONS]: { value: 1, description: "Home limit" },
|
[FeatureId.ORGINIZATIONS]: { 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 = {
|
export const tier2LimitSet: LimitSet = {
|
||||||
[LimitId.USERS]: {
|
[FeatureId.USERS]: {
|
||||||
value: 50,
|
value: 50,
|
||||||
description: "Team limit"
|
description: "Team limit"
|
||||||
},
|
},
|
||||||
[LimitId.SITES]: {
|
[FeatureId.SITES]: {
|
||||||
value: 50,
|
value: 50,
|
||||||
description: "Team limit"
|
description: "Team limit"
|
||||||
},
|
},
|
||||||
[LimitId.DOMAINS]: {
|
[FeatureId.DOMAINS]: {
|
||||||
value: 50,
|
value: 50,
|
||||||
description: "Team limit"
|
description: "Team limit"
|
||||||
},
|
},
|
||||||
[LimitId.REMOTE_EXIT_NODES]: {
|
[FeatureId.REMOTE_EXIT_NODES]: {
|
||||||
value: 3,
|
value: 3,
|
||||||
description: "Team limit"
|
description: "Team limit"
|
||||||
},
|
},
|
||||||
[LimitId.ORGANIZATIONS]: {
|
[FeatureId.ORGINIZATIONS]: {
|
||||||
value: 1,
|
value: 1,
|
||||||
description: "Team limit"
|
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 = {
|
export const tier3LimitSet: LimitSet = {
|
||||||
[LimitId.USERS]: {
|
[FeatureId.USERS]: {
|
||||||
value: 250,
|
value: 250,
|
||||||
description: "Business limit"
|
description: "Business limit"
|
||||||
},
|
},
|
||||||
[LimitId.SITES]: {
|
[FeatureId.SITES]: {
|
||||||
value: 250,
|
value: 250,
|
||||||
description: "Business limit"
|
description: "Business limit"
|
||||||
},
|
},
|
||||||
[LimitId.DOMAINS]: {
|
[FeatureId.DOMAINS]: {
|
||||||
value: 100,
|
value: 100,
|
||||||
description: "Business limit"
|
description: "Business limit"
|
||||||
},
|
},
|
||||||
[LimitId.REMOTE_EXIT_NODES]: {
|
[FeatureId.REMOTE_EXIT_NODES]: {
|
||||||
value: 20,
|
value: 20,
|
||||||
description: "Business limit"
|
description: "Business limit"
|
||||||
},
|
},
|
||||||
[LimitId.ORGANIZATIONS]: {
|
[FeatureId.ORGINIZATIONS]: {
|
||||||
value: 5,
|
value: 5,
|
||||||
description: "Business limit"
|
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" }
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { db, limits } from "@server/db";
|
import { db, limits } from "@server/db";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { LimitSet } from "./limitSet";
|
import { LimitSet } from "./limitSet";
|
||||||
import { LimitId } from "./features";
|
import { FeatureId } from "./features";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
|
||||||
class LimitService {
|
class LimitService {
|
||||||
@@ -38,7 +38,7 @@ class LimitService {
|
|||||||
|
|
||||||
async getOrgLimit(
|
async getOrgLimit(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
featureId: LimitId
|
featureId: FeatureId
|
||||||
): Promise<number | null> {
|
): Promise<number | null> {
|
||||||
const limitId = `${orgId}-${featureId}`;
|
const limitId = `${orgId}-${featureId}`;
|
||||||
const [limit] = await db
|
const [limit] = await db
|
||||||
|
|||||||
@@ -16,17 +16,15 @@ export enum TierFeature {
|
|||||||
SessionDurationPolicies = "sessionDurationPolicies", // handle downgrade by setting to default duration
|
SessionDurationPolicies = "sessionDurationPolicies", // handle downgrade by setting to default duration
|
||||||
PasswordExpirationPolicies = "passwordExpirationPolicies", // handle downgrade by setting to default duration
|
PasswordExpirationPolicies = "passwordExpirationPolicies", // handle downgrade by setting to default duration
|
||||||
AutoProvisioning = "autoProvisioning", // handle downgrade by disabling auto provisioning
|
AutoProvisioning = "autoProvisioning", // handle downgrade by disabling auto provisioning
|
||||||
|
SshPam = "sshPam",
|
||||||
FullRbac = "fullRbac",
|
FullRbac = "fullRbac",
|
||||||
SiteProvisioningKeys = "siteProvisioningKeys", // handle downgrade by revoking keys if needed
|
SiteProvisioningKeys = "siteProvisioningKeys", // handle downgrade by revoking keys if needed
|
||||||
SIEM = "siem", // handle downgrade by disabling SIEM integrations
|
SIEM = "siem", // handle downgrade by disabling SIEM integrations
|
||||||
|
HTTPPrivateResources = "httpPrivateResources", // handle downgrade by disabling HTTP private resources
|
||||||
DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces
|
DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces
|
||||||
StandaloneHealthChecks = "standaloneHealthChecks",
|
StandaloneHealthChecks = "standaloneHealthChecks",
|
||||||
AlertingRules = "alertingRules",
|
AlertingRules = "alertingRules",
|
||||||
WildcardSubdomain = "wildcardSubdomain",
|
WildcardSubdomain = "wildcardSubdomain"
|
||||||
NewtAutoUpdate = "newtAutoUpdate",
|
|
||||||
ResourcePolicies = "resourcePolicies",
|
|
||||||
AdvancedPublicResources = "advancedPublicResources",
|
|
||||||
AdvancedPrivateResources = "advancedPrivateResources"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||||
@@ -60,15 +58,13 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
|
|||||||
"enterprise"
|
"enterprise"
|
||||||
],
|
],
|
||||||
[TierFeature.AutoProvisioning]: ["tier1", "tier3", "enterprise"],
|
[TierFeature.AutoProvisioning]: ["tier1", "tier3", "enterprise"],
|
||||||
|
[TierFeature.SshPam]: ["tier1", "tier3", "enterprise"],
|
||||||
[TierFeature.FullRbac]: ["tier1", "tier2", "tier3", "enterprise"],
|
[TierFeature.FullRbac]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||||
[TierFeature.SiteProvisioningKeys]: ["tier3", "enterprise"],
|
[TierFeature.SiteProvisioningKeys]: ["tier3", "enterprise"],
|
||||||
[TierFeature.SIEM]: ["enterprise"],
|
[TierFeature.SIEM]: ["enterprise"],
|
||||||
|
[TierFeature.HTTPPrivateResources]: ["tier3", "enterprise"],
|
||||||
[TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"],
|
[TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||||
[TierFeature.StandaloneHealthChecks]: ["tier3", "enterprise"],
|
[TierFeature.StandaloneHealthChecks]: ["tier3", "enterprise"],
|
||||||
[TierFeature.AlertingRules]: ["tier3", "enterprise"],
|
[TierFeature.AlertingRules]: ["tier3", "enterprise"],
|
||||||
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"],
|
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"]
|
||||||
[TierFeature.NewtAutoUpdate]: ["tier1", "tier2", "tier3", "enterprise"],
|
|
||||||
[TierFeature.ResourcePolicies]: ["tier3", "enterprise"],
|
|
||||||
[TierFeature.AdvancedPublicResources]: ["tier3", "enterprise"],
|
|
||||||
[TierFeature.AdvancedPrivateResources]: ["tier3", "enterprise"]
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import {
|
|||||||
Transaction,
|
Transaction,
|
||||||
orgs
|
orgs
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { LimitId, getFeatureMeterId } from "./features";
|
import { FeatureId, getFeatureMeterId } from "./features";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { regionalCache as cache } from "#dynamic/lib/cache";
|
import cache from "#dynamic/lib/cache";
|
||||||
|
|
||||||
export function noop() {
|
export function noop() {
|
||||||
if (build !== "saas") {
|
if (build !== "saas") {
|
||||||
@@ -22,6 +22,7 @@ export function noop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class UsageService {
|
export class UsageService {
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (noop()) {
|
if (noop()) {
|
||||||
return;
|
return;
|
||||||
@@ -37,7 +38,7 @@ export class UsageService {
|
|||||||
|
|
||||||
public async add(
|
public async add(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
featureId: LimitId,
|
featureId: FeatureId,
|
||||||
value: number,
|
value: number,
|
||||||
transaction: any = null
|
transaction: any = null
|
||||||
): Promise<Usage | null> {
|
): Promise<Usage | null> {
|
||||||
@@ -56,10 +57,7 @@ export class UsageService {
|
|||||||
try {
|
try {
|
||||||
let usage;
|
let usage;
|
||||||
if (transaction) {
|
if (transaction) {
|
||||||
const orgIdToUse = await this.getBillingOrg(
|
const orgIdToUse = await this.getBillingOrg(orgId, transaction);
|
||||||
orgId,
|
|
||||||
transaction
|
|
||||||
);
|
|
||||||
usage = await this.internalAddUsage(
|
usage = await this.internalAddUsage(
|
||||||
orgIdToUse,
|
orgIdToUse,
|
||||||
featureId,
|
featureId,
|
||||||
@@ -114,7 +112,7 @@ export class UsageService {
|
|||||||
|
|
||||||
private async internalAddUsage(
|
private async internalAddUsage(
|
||||||
orgId: string, // here the orgId is the billing org already resolved by getBillingOrg in updateCount
|
orgId: string, // here the orgId is the billing org already resolved by getBillingOrg in updateCount
|
||||||
featureId: LimitId,
|
featureId: FeatureId,
|
||||||
value: number,
|
value: number,
|
||||||
trx: Transaction
|
trx: Transaction
|
||||||
): Promise<Usage> {
|
): Promise<Usage> {
|
||||||
@@ -163,7 +161,7 @@ export class UsageService {
|
|||||||
|
|
||||||
async updateCount(
|
async updateCount(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
featureId: LimitId,
|
featureId: FeatureId,
|
||||||
value?: number,
|
value?: number,
|
||||||
customerId?: string
|
customerId?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -227,7 +225,7 @@ export class UsageService {
|
|||||||
|
|
||||||
private async getCustomerId(
|
private async getCustomerId(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
featureId: LimitId
|
featureId: FeatureId
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const orgIdToUse = await this.getBillingOrg(orgId);
|
const orgIdToUse = await this.getBillingOrg(orgId);
|
||||||
|
|
||||||
@@ -269,19 +267,18 @@ export class UsageService {
|
|||||||
|
|
||||||
public async getUsage(
|
public async getUsage(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
featureId: LimitId,
|
featureId: FeatureId,
|
||||||
trx: Transaction | typeof db = db
|
trx: Transaction | typeof db = db
|
||||||
): Promise<Usage | null> {
|
): Promise<Usage | null> {
|
||||||
if (noop()) {
|
if (noop()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let orgIdToUse = orgId;
|
const orgIdToUse = await this.getBillingOrg(orgId, trx);
|
||||||
|
|
||||||
|
const usageId = `${orgIdToUse}-${featureId}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
orgIdToUse = await this.getBillingOrg(orgId, trx);
|
|
||||||
|
|
||||||
const usageId = `${orgIdToUse}-${featureId}`;
|
|
||||||
|
|
||||||
const [result] = await trx
|
const [result] = await trx
|
||||||
.select()
|
.select()
|
||||||
.from(usage)
|
.from(usage)
|
||||||
@@ -341,12 +338,8 @@ export class UsageService {
|
|||||||
`Failed to get usage for ${orgIdToUse}/${featureId}:`,
|
`Failed to get usage for ${orgIdToUse}/${featureId}:`,
|
||||||
error
|
error
|
||||||
);
|
);
|
||||||
if (process.env.NODE_ENV !== "development") {
|
throw error;
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getBillingOrg(
|
public async getBillingOrg(
|
||||||
@@ -381,7 +374,7 @@ export class UsageService {
|
|||||||
|
|
||||||
public async checkLimitSet(
|
public async checkLimitSet(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
featureId?: LimitId,
|
featureId?: FeatureId,
|
||||||
usage?: Usage,
|
usage?: Usage,
|
||||||
trx: Transaction | typeof db = db
|
trx: Transaction | typeof db = db
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
@@ -389,13 +382,13 @@ export class UsageService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const orgIdToUse = await this.getBillingOrg(orgId, trx);
|
||||||
|
|
||||||
// This method should check the current usage against the limits set for the organization
|
// This method should check the current usage against the limits set for the organization
|
||||||
// and kick out all of the sites on the org
|
// and kick out all of the sites on the org
|
||||||
let hasExceededLimits = false;
|
let hasExceededLimits = false;
|
||||||
let orgIdToUse = orgId;
|
|
||||||
try {
|
|
||||||
orgIdToUse = await this.getBillingOrg(orgId, trx);
|
|
||||||
|
|
||||||
|
try {
|
||||||
let orgLimits: Limit[] = [];
|
let orgLimits: Limit[] = [];
|
||||||
if (featureId) {
|
if (featureId) {
|
||||||
// Get all limits set for this organization
|
// Get all limits set for this organization
|
||||||
@@ -429,7 +422,7 @@ export class UsageService {
|
|||||||
} else {
|
} else {
|
||||||
currentUsage = await this.getUsage(
|
currentUsage = await this.getUsage(
|
||||||
orgIdToUse,
|
orgIdToUse,
|
||||||
limit.featureId as LimitId,
|
limit.featureId as FeatureId,
|
||||||
trx
|
trx
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,37 +3,29 @@ import {
|
|||||||
newts,
|
newts,
|
||||||
blueprints,
|
blueprints,
|
||||||
Blueprint,
|
Blueprint,
|
||||||
|
Site,
|
||||||
siteResources,
|
siteResources,
|
||||||
roleSiteResources,
|
roleSiteResources,
|
||||||
userSiteResources,
|
userSiteResources,
|
||||||
clientSiteResources
|
clientSiteResources
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { Config, ConfigSchema, isTargetsOnlyResource } from "./types";
|
import { Config, ConfigSchema } from "./types";
|
||||||
import {
|
import { ProxyResourcesResults, updateProxyResources } from "./proxyResources";
|
||||||
PublicResourcesResults,
|
|
||||||
updatePublicResources
|
|
||||||
} from "./publicResources";
|
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { sites } from "@server/db";
|
import { sites } from "@server/db";
|
||||||
import { eq, and, isNotNull } from "drizzle-orm";
|
import { eq, and, isNotNull } from "drizzle-orm";
|
||||||
import {
|
import { addTargets as addProxyTargets } from "@server/routers/newt/targets";
|
||||||
addTargets as addProxyTargets,
|
import { addTargets as addClientTargets } from "@server/routers/client/targets";
|
||||||
sendBrowserGatewayTargets
|
|
||||||
} from "@server/routers/newt/targets";
|
|
||||||
import {
|
import {
|
||||||
ClientResourcesResults,
|
ClientResourcesResults,
|
||||||
updatePrivateResources
|
updateClientResources
|
||||||
} from "./privateResources";
|
} from "./clientResources";
|
||||||
import { updateResourcePolicies } from "./resourcePolicies";
|
|
||||||
import { BlueprintSource } from "@server/routers/blueprints/types";
|
import { BlueprintSource } from "@server/routers/blueprints/types";
|
||||||
import { stringify as stringifyYaml } from "yaml";
|
import { stringify as stringifyYaml } from "yaml";
|
||||||
import { generateName } from "@server/db/names";
|
import { faker } from "@faker-js/faker";
|
||||||
import {
|
import { handleMessagingForUpdatedSiteResource } from "@server/routers/siteResource";
|
||||||
handleMessagingForUpdatedSiteResource,
|
import { rebuildClientAssociationsFromSiteResource } from "../rebuildClientAssociations";
|
||||||
rebuildClientAssociationsFromSiteResource,
|
|
||||||
waitForSiteResourceRebuildIdle
|
|
||||||
} from "../rebuildClientAssociations";
|
|
||||||
|
|
||||||
type ApplyBlueprintArgs = {
|
type ApplyBlueprintArgs = {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
@@ -50,39 +42,40 @@ export async function applyBlueprint({
|
|||||||
name,
|
name,
|
||||||
source = "API"
|
source = "API"
|
||||||
}: ApplyBlueprintArgs): Promise<Blueprint> {
|
}: ApplyBlueprintArgs): Promise<Blueprint> {
|
||||||
|
// Validate the input data
|
||||||
|
const validationResult = ConfigSchema.safeParse(configData);
|
||||||
|
if (!validationResult.success) {
|
||||||
|
throw new Error(fromError(validationResult.error).toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: Config = validationResult.data;
|
||||||
let blueprintSucceeded: boolean = false;
|
let blueprintSucceeded: boolean = false;
|
||||||
let blueprintMessage = "";
|
let blueprintMessage: string;
|
||||||
let error: any | null = null;
|
let error: any | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const validationResult = ConfigSchema.safeParse(configData);
|
let proxyResourcesResults: ProxyResourcesResults = [];
|
||||||
if (!validationResult.success) {
|
let clientResourcesResults: ClientResourcesResults = [];
|
||||||
throw new Error(fromError(validationResult.error).toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
const config: Config = validationResult.data;
|
|
||||||
|
|
||||||
let publicResourcesResults: PublicResourcesResults = [];
|
|
||||||
let privateResourcesResults: ClientResourcesResults = [];
|
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
await updateResourcePolicies(orgId, config, trx);
|
proxyResourcesResults = await updateProxyResources(
|
||||||
|
|
||||||
publicResourcesResults = await updatePublicResources(
|
|
||||||
orgId,
|
orgId,
|
||||||
config,
|
config,
|
||||||
trx,
|
trx,
|
||||||
siteId
|
siteId
|
||||||
);
|
);
|
||||||
privateResourcesResults = await updatePrivateResources(
|
clientResourcesResults = await updateClientResources(
|
||||||
orgId,
|
orgId,
|
||||||
config,
|
config,
|
||||||
trx,
|
trx,
|
||||||
siteId
|
siteId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`Successfully updated proxy resources for org ${orgId}: ${JSON.stringify(proxyResourcesResults)}`
|
||||||
|
);
|
||||||
|
|
||||||
// We need to update the targets on the newts from the successfully updated information
|
// We need to update the targets on the newts from the successfully updated information
|
||||||
for (const result of publicResourcesResults) {
|
for (const result of proxyResourcesResults) {
|
||||||
for (const target of result.targetsToUpdate) {
|
for (const target of result.targetsToUpdate) {
|
||||||
const [site] = await trx
|
const [site] = await trx
|
||||||
.select()
|
.select()
|
||||||
@@ -109,63 +102,178 @@ export async function applyBlueprint({
|
|||||||
(hc) => hc.targetId === target.targetId
|
(hc) => hc.targetId === target.targetId
|
||||||
);
|
);
|
||||||
|
|
||||||
if (["http", "tcp", "udp"].includes(target.mode)) {
|
await addProxyTargets(
|
||||||
await addProxyTargets(
|
site.newt.newtId,
|
||||||
site.newt.newtId,
|
[target],
|
||||||
[target],
|
matchingHealthcheck ? [matchingHealthcheck] : [],
|
||||||
matchingHealthcheck
|
result.proxyResource.protocol,
|
||||||
? [matchingHealthcheck]
|
site.newt.version
|
||||||
: [],
|
);
|
||||||
result.proxyResource.mode === "udp"
|
|
||||||
? "udp"
|
|
||||||
: "tcp",
|
|
||||||
site.newt.version
|
|
||||||
);
|
|
||||||
} else if (
|
|
||||||
["ssh", "rdp", "vnc"].includes(target.mode)
|
|
||||||
) {
|
|
||||||
await sendBrowserGatewayTargets(
|
|
||||||
site.newt.newtId,
|
|
||||||
[target],
|
|
||||||
site.newt.version
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Successfully updated public resources for org ${orgId}: ${JSON.stringify(publicResourcesResults)}`
|
`Successfully updated client resources for org ${orgId}: ${JSON.stringify(clientResourcesResults)}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// We need to update the targets on the newts from the successfully updated information
|
// We need to update the targets on the newts from the successfully updated information
|
||||||
for (const result of privateResourcesResults) {
|
for (const result of clientResourcesResults) {
|
||||||
rebuildClientAssociationsFromSiteResource(
|
if (
|
||||||
result.newSiteResource
|
result.oldSiteResource &&
|
||||||
)
|
JSON.stringify(result.newSites?.sort()) !==
|
||||||
.then(() =>
|
JSON.stringify(result.oldSites?.sort())
|
||||||
waitForSiteResourceRebuildIdle(
|
) {
|
||||||
result.newSiteResource.siteResourceId
|
// query existing associations
|
||||||
|
const existingRoleIds = await trx
|
||||||
|
.select()
|
||||||
|
.from(roleSiteResources)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
roleSiteResources.siteResourceId,
|
||||||
|
result.oldSiteResource.siteResourceId
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
.then((rows) => rows.map((row) => row.roleId));
|
||||||
.then(() =>
|
|
||||||
handleMessagingForUpdatedSiteResource(
|
|
||||||
result.oldSiteResource,
|
|
||||||
result.newSiteResource,
|
|
||||||
result.oldSites.map((s) => s.siteId),
|
|
||||||
result.newSites.map((s) => s.siteId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.catch((e) => {
|
|
||||||
logger.error(
|
|
||||||
`Failed to rebuild and handle messaging for site resource ${result.newSiteResource.siteResourceId}. Error: ${e}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug(
|
const existingUserIds = await trx
|
||||||
`Successfully updated private resources for org ${orgId}: ${JSON.stringify(privateResourcesResults)}`
|
.select()
|
||||||
);
|
.from(userSiteResources)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
userSiteResources.siteResourceId,
|
||||||
|
result.oldSiteResource.siteResourceId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.then((rows) => rows.map((row) => row.userId));
|
||||||
|
|
||||||
|
const existingClientIds = await trx
|
||||||
|
.select()
|
||||||
|
.from(clientSiteResources)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
clientSiteResources.siteResourceId,
|
||||||
|
result.oldSiteResource.siteResourceId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.then((rows) => rows.map((row) => row.clientId));
|
||||||
|
|
||||||
|
// delete the existing site resource
|
||||||
|
await trx
|
||||||
|
.delete(siteResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
siteResources.siteResourceId,
|
||||||
|
result.oldSiteResource.siteResourceId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await rebuildClientAssociationsFromSiteResource(
|
||||||
|
result.oldSiteResource,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
|
||||||
|
const [insertedSiteResource] = await trx
|
||||||
|
.insert(siteResources)
|
||||||
|
.values({
|
||||||
|
...result.newSiteResource
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// wait some time to allow for messages to be handled
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 750));
|
||||||
|
|
||||||
|
//////////////////// update the associations ////////////////////
|
||||||
|
|
||||||
|
if (existingRoleIds.length > 0) {
|
||||||
|
await trx.insert(roleSiteResources).values(
|
||||||
|
existingRoleIds.map((roleId) => ({
|
||||||
|
roleId,
|
||||||
|
siteResourceId:
|
||||||
|
insertedSiteResource!.siteResourceId
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingUserIds.length > 0) {
|
||||||
|
await trx.insert(userSiteResources).values(
|
||||||
|
existingUserIds.map((userId) => ({
|
||||||
|
userId,
|
||||||
|
siteResourceId:
|
||||||
|
insertedSiteResource!.siteResourceId
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingClientIds.length > 0) {
|
||||||
|
await trx.insert(clientSiteResources).values(
|
||||||
|
existingClientIds.map((clientId) => ({
|
||||||
|
clientId,
|
||||||
|
siteResourceId:
|
||||||
|
insertedSiteResource!.siteResourceId
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await rebuildClientAssociationsFromSiteResource(
|
||||||
|
insertedSiteResource,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
let good = true;
|
||||||
|
for (const newSite of result.newSites) {
|
||||||
|
const [site] = await trx
|
||||||
|
.select()
|
||||||
|
.from(sites)
|
||||||
|
.innerJoin(newts, eq(sites.siteId, newts.siteId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sites.siteId, newSite.siteId),
|
||||||
|
eq(sites.orgId, orgId),
|
||||||
|
eq(sites.type, "newt"),
|
||||||
|
isNotNull(sites.pubKey)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!site) {
|
||||||
|
logger.debug(
|
||||||
|
`No newt sites found for client resource ${result.newSiteResource.siteResourceId}, skipping target update`
|
||||||
|
);
|
||||||
|
good = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`Updating client resource ${result.newSiteResource.siteResourceId} on site ${newSite.siteId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!good) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleMessagingForUpdatedSiteResource(
|
||||||
|
result.oldSiteResource,
|
||||||
|
result.newSiteResource,
|
||||||
|
result.newSites.map((site) => ({
|
||||||
|
siteId: site.siteId,
|
||||||
|
orgId: result.newSiteResource.orgId
|
||||||
|
})),
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// await addClientTargets(
|
||||||
|
// site.newt.newtId,
|
||||||
|
// result.resource.destination,
|
||||||
|
// result.resource.destinationPort,
|
||||||
|
// result.resource.protocol,
|
||||||
|
// result.resource.proxyPort
|
||||||
|
// );
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
blueprintSucceeded = true;
|
blueprintSucceeded = true;
|
||||||
@@ -173,9 +281,7 @@ export async function applyBlueprint({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
blueprintSucceeded = false;
|
blueprintSucceeded = false;
|
||||||
blueprintMessage = `Blueprint applied with errors: ${err}`;
|
blueprintMessage = `Blueprint applied with errors: ${err}`;
|
||||||
logger.debug(
|
logger.error(blueprintMessage);
|
||||||
`Org ${orgId} blueprint apply issues: ${blueprintMessage}`
|
|
||||||
);
|
|
||||||
error = err;
|
error = err;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,7 +291,9 @@ export async function applyBlueprint({
|
|||||||
.insert(blueprints)
|
.insert(blueprints)
|
||||||
.values({
|
.values({
|
||||||
orgId,
|
orgId,
|
||||||
name: name ?? generateName(),
|
name:
|
||||||
|
name ??
|
||||||
|
`${faker.word.adjective()}-${faker.word.adjective()}-${faker.word.noun()}`,
|
||||||
contents: stringifyYaml(configData),
|
contents: stringifyYaml(configData),
|
||||||
createdAt: Math.floor(Date.now() / 1000),
|
createdAt: Math.floor(Date.now() / 1000),
|
||||||
succeeded: blueprintSucceeded,
|
succeeded: blueprintSucceeded,
|
||||||
|
|||||||
@@ -1,56 +1,10 @@
|
|||||||
import { sendToClient } from "#dynamic/routers/ws";
|
import { sendToClient } from "#dynamic/routers/ws";
|
||||||
import { processContainerLabels } from "./parseDockerContainers";
|
import { processContainerLabels } from "./parseDockerContainers";
|
||||||
import { applyBlueprint } from "./applyBlueprint";
|
import { applyBlueprint } from "./applyBlueprint";
|
||||||
import { PrivateResourceSchema, PublicResourceSchema } from "./types";
|
|
||||||
import { db, sites } from "@server/db";
|
import { db, sites } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
|
||||||
type BlueprintResult = ReturnType<typeof processContainerLabels>;
|
|
||||||
|
|
||||||
function filterInvalidResources(blueprint: BlueprintResult): {
|
|
||||||
skippedCount: number;
|
|
||||||
skippedKeys: string[];
|
|
||||||
} {
|
|
||||||
const skippedKeys: string[] = [];
|
|
||||||
|
|
||||||
for (const section of ["proxy-resources", "public-resources"] as const) {
|
|
||||||
const resources = blueprint[section];
|
|
||||||
for (const [key, value] of Object.entries(resources)) {
|
|
||||||
const result = PublicResourceSchema.safeParse(value);
|
|
||||||
if (!result.success) {
|
|
||||||
const errors = result.error.issues
|
|
||||||
.map((i) => `${i.path.join(".")}: ${i.message}`)
|
|
||||||
.join("; ");
|
|
||||||
logger.warn(
|
|
||||||
`Skipping invalid Docker ${section} "${key}": ${errors}`
|
|
||||||
);
|
|
||||||
delete resources[key];
|
|
||||||
skippedKeys.push(`${section}.${key}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const section of ["client-resources", "private-resources"] as const) {
|
|
||||||
const resources = blueprint[section];
|
|
||||||
for (const [key, value] of Object.entries(resources)) {
|
|
||||||
const result = PrivateResourceSchema.safeParse(value);
|
|
||||||
if (!result.success) {
|
|
||||||
const errors = result.error.issues
|
|
||||||
.map((i) => `${i.path.join(".")}: ${i.message}`)
|
|
||||||
.join("; ");
|
|
||||||
logger.warn(
|
|
||||||
`Skipping invalid Docker ${section} "${key}": ${errors}`
|
|
||||||
);
|
|
||||||
delete resources[key];
|
|
||||||
skippedKeys.push(`${section}.${key}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { skippedCount: skippedKeys.length, skippedKeys };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function applyNewtDockerBlueprint(
|
export async function applyNewtDockerBlueprint(
|
||||||
siteId: number,
|
siteId: number,
|
||||||
newtId: string,
|
newtId: string,
|
||||||
@@ -67,27 +21,17 @@ export async function applyNewtDockerBlueprint(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let skippedCount = 0;
|
// logger.debug(`Applying Docker blueprint to site: ${siteId}`);
|
||||||
let skippedKeys: string[] = [];
|
// logger.debug(`Containers: ${JSON.stringify(containers, null, 2)}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Some Newt clients can report null/undefined containers when Docker
|
const blueprint = processContainerLabels(containers);
|
||||||
// labels are unavailable. Treat that as an empty blueprint payload.
|
|
||||||
const safeContainers = Array.isArray(containers) ? containers : [];
|
|
||||||
const blueprint = processContainerLabels(safeContainers);
|
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(`Received Docker blueprint: ${JSON.stringify(blueprint)}`);
|
||||||
`Received Docker blueprint with ${Object.keys(blueprint["proxy-resources"]).length} proxy, ${Object.keys(blueprint["client-resources"]).length} client resource(s)`
|
|
||||||
);
|
|
||||||
|
|
||||||
const filterResult = filterInvalidResources(blueprint);
|
// make sure this is not an empty object
|
||||||
skippedCount = filterResult.skippedCount;
|
if (isEmptyObject(blueprint)) {
|
||||||
skippedKeys = filterResult.skippedKeys;
|
return;
|
||||||
|
|
||||||
if (skippedCount > 0) {
|
|
||||||
logger.warn(
|
|
||||||
`Filtered ${skippedCount} invalid resource(s) from Docker blueprint: ${skippedKeys.join(", ")}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -96,15 +40,6 @@ export async function applyNewtDockerBlueprint(
|
|||||||
isEmptyObject(blueprint["public-resources"]) &&
|
isEmptyObject(blueprint["public-resources"]) &&
|
||||||
isEmptyObject(blueprint["private-resources"])
|
isEmptyObject(blueprint["private-resources"])
|
||||||
) {
|
) {
|
||||||
if (skippedCount > 0) {
|
|
||||||
await sendToClient(newtId, {
|
|
||||||
type: "newt/blueprint/results",
|
|
||||||
data: {
|
|
||||||
success: false,
|
|
||||||
message: `All resources were invalid and skipped: ${skippedKeys.join(", ")}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +51,7 @@ export async function applyNewtDockerBlueprint(
|
|||||||
source: "NEWT"
|
source: "NEWT"
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.debug(`Failed to update database from config: ${error}`);
|
logger.error(`Failed to update database from config: ${error}`);
|
||||||
await sendToClient(newtId, {
|
await sendToClient(newtId, {
|
||||||
type: "newt/blueprint/results",
|
type: "newt/blueprint/results",
|
||||||
data: {
|
data: {
|
||||||
@@ -131,10 +66,7 @@ export async function applyNewtDockerBlueprint(
|
|||||||
type: "newt/blueprint/results",
|
type: "newt/blueprint/results",
|
||||||
data: {
|
data: {
|
||||||
success: true,
|
success: true,
|
||||||
message:
|
message: "Config updated successfully"
|
||||||
skippedCount > 0
|
|
||||||
? `Config updated successfully. Skipped ${skippedCount} invalid resource(s): ${skippedKeys.join(", ")}`
|
|
||||||
: "Config updated successfully"
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-196
@@ -3,7 +3,6 @@ import {
|
|||||||
clientSiteResources,
|
clientSiteResources,
|
||||||
domains,
|
domains,
|
||||||
orgDomains,
|
orgDomains,
|
||||||
roleActions,
|
|
||||||
roles,
|
roles,
|
||||||
roleSiteResources,
|
roleSiteResources,
|
||||||
Site,
|
Site,
|
||||||
@@ -20,14 +19,8 @@ import { sites } from "@server/db";
|
|||||||
import { eq, and, ne, inArray, or, isNotNull } from "drizzle-orm";
|
import { eq, and, ne, inArray, or, isNotNull } from "drizzle-orm";
|
||||||
import { Config } from "./types";
|
import { Config } from "./types";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
|
|
||||||
import { getNextAvailableAliasAddress } from "../ip";
|
import { getNextAvailableAliasAddress } from "../ip";
|
||||||
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
||||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
|
||||||
import { tierMatrix } from "../billing/tierMatrix";
|
|
||||||
import { build } from "@server/build";
|
|
||||||
import { LimitId } from "../billing";
|
|
||||||
import { usageService } from "../billing/usageService";
|
|
||||||
|
|
||||||
async function getDomainForSiteResource(
|
async function getDomainForSiteResource(
|
||||||
siteResourceId: number | undefined,
|
siteResourceId: number | undefined,
|
||||||
@@ -108,7 +101,7 @@ export type ClientResourcesResults = {
|
|||||||
oldSites: { siteId: number }[];
|
oldSites: { siteId: number }[];
|
||||||
}[];
|
}[];
|
||||||
|
|
||||||
export async function updatePrivateResources(
|
export async function updateClientResources(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
config: Config,
|
config: Config,
|
||||||
trx: Transaction,
|
trx: Transaction,
|
||||||
@@ -119,30 +112,6 @@ export async function updatePrivateResources(
|
|||||||
for (const [resourceNiceId, resourceData] of Object.entries(
|
for (const [resourceNiceId, resourceData] of Object.entries(
|
||||||
config["client-resources"]
|
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
|
const [existingResource] = await trx
|
||||||
.select()
|
.select()
|
||||||
.from(siteResources)
|
.from(siteResources)
|
||||||
@@ -198,19 +167,17 @@ export async function updatePrivateResources(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let resourceStatusFromSite: "approved" | "pending" = "approved";
|
|
||||||
if (siteId && allSites.length === 0) {
|
if (siteId && allSites.length === 0) {
|
||||||
// only add if there are not provided sites
|
// only add if there are not provided sites
|
||||||
// Use the provided siteId directly, but verify it belongs to the org
|
// Use the provided siteId directly, but verify it belongs to the org
|
||||||
const [siteSingle] = await trx
|
const [siteSingle] = await trx
|
||||||
.select({ siteId: sites.siteId, status: sites.status })
|
.select({ siteId: sites.siteId })
|
||||||
.from(sites)
|
.from(sites)
|
||||||
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
|
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (siteSingle) {
|
if (siteSingle) {
|
||||||
allSites.push(siteSingle);
|
allSites.push(siteSingle);
|
||||||
}
|
}
|
||||||
resourceStatusFromSite = siteSingle.status ?? "approved";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allSites.length === 0) {
|
if (allSites.length === 0) {
|
||||||
@@ -219,13 +186,6 @@ export async function updatePrivateResources(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const resourceEnabled =
|
|
||||||
resourceData.enabled == undefined || resourceData.enabled == null
|
|
||||||
? true
|
|
||||||
: resourceStatusFromSite === "pending"
|
|
||||||
? false
|
|
||||||
: resourceData.enabled;
|
|
||||||
|
|
||||||
if (existingResource) {
|
if (existingResource) {
|
||||||
let domainInfo:
|
let domainInfo:
|
||||||
| { subdomain: string | null; domainId: string }
|
| { subdomain: string | null; domainId: string }
|
||||||
@@ -239,31 +199,6 @@ export async function updatePrivateResources(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update existing resource
|
// Update existing resource
|
||||||
const [updatedResource] = await trx
|
const [updatedResource] = await trx
|
||||||
.update(siteResources)
|
.update(siteResources)
|
||||||
@@ -274,7 +209,8 @@ export async function updatePrivateResources(
|
|||||||
scheme: resourceData.scheme,
|
scheme: resourceData.scheme,
|
||||||
destination: resourceData.destination,
|
destination: resourceData.destination,
|
||||||
destinationPort: resourceData["destination-port"],
|
destinationPort: resourceData["destination-port"],
|
||||||
enabled: resourceEnabled,
|
enabled: true, // hardcoded for now
|
||||||
|
// enabled: resourceData.enabled ?? true,
|
||||||
alias: resourceData.alias || null,
|
alias: resourceData.alias || null,
|
||||||
disableIcmp:
|
disableIcmp:
|
||||||
resourceData["disable-icmp"] ||
|
resourceData["disable-icmp"] ||
|
||||||
@@ -289,12 +225,7 @@ export async function updatePrivateResources(
|
|||||||
: resourceData["udp-ports"],
|
: resourceData["udp-ports"],
|
||||||
fullDomain: resourceData["full-domain"] || null,
|
fullDomain: resourceData["full-domain"] || null,
|
||||||
subdomain: domainInfo ? domainInfo.subdomain : null,
|
subdomain: domainInfo ? domainInfo.subdomain : null,
|
||||||
domainId: domainInfo ? domainInfo.domainId : null,
|
domainId: domainInfo ? domainInfo.domainId : null
|
||||||
pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
|
|
||||||
authDaemonMode:
|
|
||||||
resourceData["auth-daemon"]?.mode || "native",
|
|
||||||
authDaemonPort: resourceData["auth-daemon"]?.port || 22123,
|
|
||||||
status: resourceStatusFromSite
|
|
||||||
})
|
})
|
||||||
.where(
|
.where(
|
||||||
eq(
|
eq(
|
||||||
@@ -401,7 +332,8 @@ export async function updatePrivateResources(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (resourceData.roles.length > 0) {
|
if (resourceData.roles.length > 0) {
|
||||||
const existingRoles = await trx
|
// Re-add specified roles but we need to get the roleIds from the role name in the array
|
||||||
|
const rolesToUpdate = await trx
|
||||||
.select()
|
.select()
|
||||||
.from(roles)
|
.from(roles)
|
||||||
.where(
|
.where(
|
||||||
@@ -411,30 +343,7 @@ export async function updatePrivateResources(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const foundNames = new Set(existingRoles.map((r) => r.name));
|
const roleIds = rolesToUpdate.map((role) => role.roleId);
|
||||||
const missingNames = resourceData.roles.filter(
|
|
||||||
(n) => !foundNames.has(n)
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const name of missingNames) {
|
|
||||||
const [created] = await trx
|
|
||||||
.insert(roles)
|
|
||||||
.values({ name, orgId })
|
|
||||||
.returning();
|
|
||||||
await trx.insert(roleActions).values(
|
|
||||||
defaultRoleAllowedActions.map((action) => ({
|
|
||||||
roleId: created.roleId,
|
|
||||||
actionId: action,
|
|
||||||
orgId
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
existingRoles.push(created);
|
|
||||||
logger.info(
|
|
||||||
`Auto-created role "${name}" in org ${orgId} from blueprint`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const roleIds = existingRoles.map((role) => role.roleId);
|
|
||||||
|
|
||||||
await trx
|
await trx
|
||||||
.insert(roleSiteResources)
|
.insert(roleSiteResources)
|
||||||
@@ -450,47 +359,9 @@ export async function updatePrivateResources(
|
|||||||
oldSites: existingSiteIds
|
oldSites: existingSiteIds
|
||||||
});
|
});
|
||||||
} else {
|
} 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 aliasAddress: string | null = null;
|
||||||
let releaseAliasLock: (() => Promise<void>) | null = null;
|
if (resourceData.mode === "host" || resourceData.mode === "http") {
|
||||||
if (
|
aliasAddress = await getNextAvailableAliasAddress(orgId, trx);
|
||||||
resourceData.mode === "host" ||
|
|
||||||
resourceData.mode === "http" ||
|
|
||||||
resourceData.mode === "ssh"
|
|
||||||
) {
|
|
||||||
const { value, release } = await getNextAvailableAliasAddress(
|
|
||||||
orgId,
|
|
||||||
trx
|
|
||||||
);
|
|
||||||
aliasAddress = value;
|
|
||||||
releaseAliasLock = release;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let domainInfo:
|
let domainInfo:
|
||||||
@@ -505,27 +376,6 @@ export async function updatePrivateResources(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [network] = await trx
|
const [network] = await trx
|
||||||
.insert(networks)
|
.insert(networks)
|
||||||
.values({
|
.values({
|
||||||
@@ -548,7 +398,8 @@ export async function updatePrivateResources(
|
|||||||
scheme: resourceData.scheme,
|
scheme: resourceData.scheme,
|
||||||
destination: resourceData.destination,
|
destination: resourceData.destination,
|
||||||
destinationPort: resourceData["destination-port"],
|
destinationPort: resourceData["destination-port"],
|
||||||
enabled: resourceEnabled,
|
enabled: true, // hardcoded for now
|
||||||
|
// enabled: resourceData.enabled ?? true,
|
||||||
alias: resourceData.alias || null,
|
alias: resourceData.alias || null,
|
||||||
aliasAddress: aliasAddress,
|
aliasAddress: aliasAddress,
|
||||||
disableIcmp:
|
disableIcmp:
|
||||||
@@ -564,17 +415,10 @@ export async function updatePrivateResources(
|
|||||||
: resourceData["udp-ports"],
|
: resourceData["udp-ports"],
|
||||||
fullDomain: resourceData["full-domain"] || null,
|
fullDomain: resourceData["full-domain"] || null,
|
||||||
subdomain: domainInfo ? domainInfo.subdomain : null,
|
subdomain: domainInfo ? domainInfo.subdomain : null,
|
||||||
domainId: domainInfo ? domainInfo.domainId : null,
|
domainId: domainInfo ? domainInfo.domainId : null
|
||||||
pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
|
|
||||||
authDaemonMode:
|
|
||||||
resourceData["auth-daemon"]?.mode || "native",
|
|
||||||
authDaemonPort: resourceData["auth-daemon"]?.port || 22123,
|
|
||||||
status: resourceStatusFromSite
|
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
await releaseAliasLock?.();
|
|
||||||
|
|
||||||
const siteResourceId = newResource.siteResourceId;
|
const siteResourceId = newResource.siteResourceId;
|
||||||
|
|
||||||
for (const site of allSites) {
|
for (const site of allSites) {
|
||||||
@@ -600,7 +444,8 @@ export async function updatePrivateResources(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (resourceData.roles.length > 0) {
|
if (resourceData.roles.length > 0) {
|
||||||
const existingRoles = await trx
|
// get roleIds from role names
|
||||||
|
const rolesToUpdate = await trx
|
||||||
.select()
|
.select()
|
||||||
.from(roles)
|
.from(roles)
|
||||||
.where(
|
.where(
|
||||||
@@ -610,30 +455,7 @@ export async function updatePrivateResources(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const foundNames = new Set(existingRoles.map((r) => r.name));
|
const roleIds = rolesToUpdate.map((role) => role.roleId);
|
||||||
const missingNames = resourceData.roles.filter(
|
|
||||||
(n) => !foundNames.has(n)
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const name of missingNames) {
|
|
||||||
const [created] = await trx
|
|
||||||
.insert(roles)
|
|
||||||
.values({ name, orgId })
|
|
||||||
.returning();
|
|
||||||
await trx.insert(roleActions).values(
|
|
||||||
defaultRoleAllowedActions.map((action) => ({
|
|
||||||
roleId: created.roleId,
|
|
||||||
actionId: action,
|
|
||||||
orgId
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
existingRoles.push(created);
|
|
||||||
logger.info(
|
|
||||||
`Auto-created role "${name}" in org ${orgId} from blueprint`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const roleIds = existingRoles.map((role) => role.roleId);
|
|
||||||
|
|
||||||
await trx
|
await trx
|
||||||
.insert(roleSiteResources)
|
.insert(roleSiteResources)
|
||||||
@@ -695,8 +517,6 @@ export async function updatePrivateResources(
|
|||||||
`Created new client resource ${newResource.name} (${newResource.siteResourceId}) for org ${orgId}`
|
`Created new client resource ${newResource.name} (${newResource.siteResourceId}) for org ${orgId}`
|
||||||
);
|
);
|
||||||
|
|
||||||
await usageService.add(orgId, LimitId.PRIVATE_RESOURCES, 1, trx);
|
|
||||||
|
|
||||||
results.push({
|
results.push({
|
||||||
newSiteResource: newResource,
|
newSiteResource: newResource,
|
||||||
newSites: allSites,
|
newSites: allSites,
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user