Compare commits

..

13 Commits

Author SHA1 Message Date
Owen
6337cf4359 Fix #3104 2026-05-20 16:14:47 -07:00
Owen Schwartz
b3cfe82dff Merge pull request #3124 from fosrl/fix-logoUrl
Fix logo url
2026-05-20 14:19:29 -07:00
Owen
d65128671c Fix logo url 2026-05-20 14:18:55 -07:00
Owen Schwartz
41fdd5de74 Merge pull request #3122 from fosrl/button-to-rebuild-association
Add button to rebuid cache
2026-05-20 12:08:47 -07:00
Owen
2704202ba9 Add button to rebuid cache 2026-05-20 12:08:20 -07:00
Owen Schwartz
72ef0ae020 Merge pull request #3121 from fosrl/patch-rebuild-sites
patch rebuild sites
2026-05-20 11:48:33 -07:00
Owen
1442faa740 Prevent concurrent rebuilds 2026-05-20 11:46:59 -07:00
Owen
6aa589e612 Block adds to clients in jit mode 2026-05-20 11:35:15 -07:00
Owen
4b1a8e14c4 Put long running into the background to end transaction 2026-05-20 11:18:47 -07:00
Owen
1a0db10b1a Verify button to verify cache 2026-05-20 11:15:15 -07:00
Owen
b7634086db Just accept any url for now 2026-05-20 10:47:37 -07:00
Owen Schwartz
1ba75092f9 Merge pull request #3113 from fosrl/dev
derived only from roles that the user holds AND are assigned to the target resource
2026-05-19 10:56:30 -07:00
Owen
08a08e73b3 derived only from roles that the user holds AND are assigned to the target resource 2026-05-19 10:53:54 -07:00
11 changed files with 709 additions and 170 deletions

71
package-lock.json generated
View File

@@ -1058,6 +1058,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -2353,6 +2354,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2375,6 +2377,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2397,6 +2400,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2413,6 +2417,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2429,6 +2434,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2445,6 +2451,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2461,6 +2468,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2477,6 +2485,7 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2493,6 +2502,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2509,6 +2519,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2525,6 +2536,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2541,6 +2553,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2563,6 +2576,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2585,6 +2599,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2607,6 +2622,7 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2629,6 +2645,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2651,6 +2668,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2673,6 +2691,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2695,6 +2714,7 @@
"cpu": [
"wasm32"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
@@ -2714,6 +2734,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2733,6 +2754,7 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2752,6 +2774,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3011,6 +3034,7 @@
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": "^14.21.3 || >=16"
},
@@ -6957,6 +6981,7 @@
"resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.6.tgz",
"integrity": "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.0.0"
},
@@ -8417,6 +8442,7 @@
"version": "5.90.21",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz",
"integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==",
"peer": true,
"dependencies": {
"@tanstack/query-core": "5.90.20"
},
@@ -8532,6 +8558,7 @@
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/node": "*"
}
@@ -8879,6 +8906,7 @@
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0",
@@ -8974,6 +9002,7 @@
"integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.18.0"
}
@@ -9001,6 +9030,7 @@
"integrity": "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
@@ -9026,6 +9056,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true,
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -9036,6 +9067,7 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -9122,8 +9154,7 @@
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true,
"peer": true
"optional": true
},
"node_modules/@types/ws": {
"version": "8.18.1",
@@ -9197,6 +9228,7 @@
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1",
@@ -9670,6 +9702,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -10119,6 +10152,7 @@
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/types": "^7.26.0"
}
@@ -10190,6 +10224,7 @@
"integrity": "sha512-Ba0KR+Fzxh2jDRhdg6TSH0SJGzb8C0aBY4hR8w8madIdIzzC6Y1+kx5qR6eS1Z+Gy20h6ZU28aeyg0z1VIrShQ==",
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
@@ -10275,9 +10310,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
@@ -10318,6 +10353,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -11224,6 +11260,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -11664,7 +11701,6 @@
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
"integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"peer": true,
"engines": {
"node": ">=20"
},
@@ -12299,6 +12335,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
@@ -12384,6 +12421,7 @@
"integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2",
@@ -12469,9 +12507,9 @@
"license": "MIT"
},
"node_modules/eslint-config-next/node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -12520,6 +12558,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -12913,6 +12952,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -15330,7 +15370,6 @@
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
"license": "MIT",
"peer": true,
"dependencies": {
"dompurify": "3.2.7",
"marked": "14.0.0"
@@ -15341,7 +15380,6 @@
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
"license": "MIT",
"peer": true,
"bin": {
"marked": "bin/marked.js"
},
@@ -15430,6 +15468,7 @@
"resolved": "https://registry.npmjs.org/next/-/next-15.5.15.tgz",
"integrity": "sha512-VSqCrJwtLVGwAVE0Sb/yikrQfkwkZW9p+lL/J4+xe+G3ZA+QnWPqgcfH1tDUEuk9y+pthzzVFp4L/U8JerMfMQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@next/env": "15.5.15",
"@swc/helpers": "0.5.15",
@@ -16389,6 +16428,7 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.12.0",
"pg-pool": "^3.13.0",
@@ -16896,6 +16936,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -16927,6 +16968,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -17219,6 +17261,7 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz",
"integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -18680,7 +18723,8 @@
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/tapable": {
"version": "2.3.2",
@@ -19155,6 +19199,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -19582,6 +19627,7 @@
"resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz",
"integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@colors/colors": "^1.6.0",
"@dabh/diagnostics": "^2.0.8",
@@ -19788,6 +19834,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -82,7 +82,7 @@ export const RuleSchema = z
.object({
action: z.enum(["allow", "deny", "pass"]),
match: z.enum(["cidr", "path", "ip", "country", "asn", "region"]),
value: z.string(),
value: z.coerce.string(),
priority: z.int().optional()
})
.refine(
@@ -340,7 +340,8 @@ export const ResourceSchema = z
if (parts.includes("*", 1)) return false; // no further wildcards
if (parts.length < 3) return false; // need at least *.label.tld
const labelRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$|^[a-zA-Z0-9]$/;
const labelRegex =
/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$|^[a-zA-Z0-9]$/;
return parts.slice(1).every((label) => labelRegex.test(label));
},
{

View File

@@ -18,7 +18,7 @@ import {
userOrgRoles,
userSiteResources
} from "@server/db";
import { and, eq, inArray, ne } from "drizzle-orm";
import { and, count, eq, inArray, ne } from "drizzle-orm";
import { deletePeer as newtDeletePeer } from "@server/routers/newt/peers";
import {
@@ -39,6 +39,11 @@ import {
removePeerData,
removeTargets as removeSubnetProxyTargets
} from "@server/routers/client/targets";
import { lockManager } from "#dynamic/lib/lock";
// TTL for rebuild-association locks. These functions can fan out into many
// peer/proxy updates, so give them a generous window.
const REBUILD_ASSOCIATIONS_LOCK_TTL_MS = 120000;
export async function getClientSiteResourceAccess(
siteResource: SiteResource,
@@ -161,6 +166,23 @@ export async function rebuildClientAssociationsFromSiteResource(
pubKey: string | null;
subnet: string | null;
}[];
}> {
return await lockManager.withLock(
`rebuild-client-associations:site-resource:${siteResource.siteResourceId}`,
() => rebuildClientAssociationsFromSiteResourceImpl(siteResource, trx),
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
);
}
async function rebuildClientAssociationsFromSiteResourceImpl(
siteResource: SiteResource,
trx: Transaction | typeof db = db
): Promise<{
mergedAllClients: {
clientId: number;
pubKey: string | null;
subnet: string | null;
}[];
}> {
logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] START siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} orgId=${siteResource.orgId}`
@@ -539,6 +561,29 @@ async function handleMessagesForSiteClients(
}
}
// get the number of sites on each of these clients so we can log it and make decisions about whether to send messages based on it
const clientSiteCounts: Record<number, number> = {};
if (clientsToProcess.size > 0) {
const clientIdsToProcess = Array.from(clientsToProcess.keys());
const siteCounts = await trx
.select({
clientId: clientSitesAssociationsCache.clientId,
siteCount: count(clientSitesAssociationsCache.siteId)
})
.from(clientSitesAssociationsCache)
.where(
inArray(
clientSitesAssociationsCache.clientId,
clientIdsToProcess
)
)
.groupBy(clientSitesAssociationsCache.clientId);
for (const row of siteCounts) {
clientSiteCounts[row.clientId] = Number(row.siteCount);
}
}
for (const client of clientsToProcess.values()) {
// UPDATE THE NEWT
if (!client.subnet || !client.pubKey) {
@@ -582,7 +627,14 @@ async function handleMessagesForSiteClients(
}
if (isAdd) {
// TODO: if we are in jit mode here should we really be sending this?
if (clientSiteCounts[client.clientId] > 250) {
// skip adding the peer if we have more than 250 sites because we are in jit mode anyway
logger.info(
`rebuildClientAssociations: Client ${client.clientId} has ${clientSiteCounts[client.clientId]} sites so skipping adding peer to newt and olm because it is likely in jit mode`
);
continue;
}
await initPeerAddHandshake(
// this will kick off the add peer process for the client
client.clientId,
@@ -600,9 +652,24 @@ async function handleMessagesForSiteClients(
exitNodeJobs.push(updateClientSiteDestinations(client, trx));
}
await Promise.all(exitNodeJobs);
await Promise.all(newtJobs); // do the servers first to make sure they are ready?
await Promise.all(olmJobs);
Promise.all(exitNodeJobs).catch((error) => {
logger.error(
`rebuildClientAssociations: Error updating client site destinations for site ${site.siteId}:`,
error
);
});
Promise.all(newtJobs).catch((error) => {
logger.error(
`rebuildClientAssociations: Error updating Newt peers for site ${site.siteId}:`,
error
);
});
Promise.all(olmJobs).catch((error) => {
logger.error(
`rebuildClientAssociations: Error updating Olm peers for site ${site.siteId}:`,
error
);
});
}
interface PeerDestination {
@@ -885,6 +952,17 @@ async function handleSubnetProxyTargetUpdates(
export async function rebuildClientAssociationsFromClient(
client: Client,
trx: Transaction | typeof db = db
): Promise<void> {
return await lockManager.withLock(
`rebuild-client-associations:client:${client.clientId}`,
() => rebuildClientAssociationsFromClientImpl(client, trx),
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
);
}
async function rebuildClientAssociationsFromClientImpl(
client: Client,
trx: Transaction | typeof db = db
): Promise<void> {
let newSiteResourceIds: number[] = [];
@@ -1157,6 +1235,12 @@ async function handleMessagesForClientSites(
const olmJobs: Promise<any>[] = [];
const exitNodeJobs: Promise<any>[] = [];
const totalSitesOnClient = await trx
.select({ count: count(clientSitesAssociationsCache.siteId) })
.from(clientSitesAssociationsCache)
.where(eq(clientSitesAssociationsCache.clientId, client.clientId))
.then((rows) => Number(rows[0].count));
for (const siteData of sitesData) {
const site = siteData.sites;
const exitNode = siteData.exitNodes;
@@ -1217,7 +1301,14 @@ async function handleMessagesForClientSites(
continue;
}
// TODO: if we are in jit mode here should we really be sending this?
if (totalSitesOnClient > 250) {
// skip adding the site if we have more than 250 because we are in jit mode anyway
logger.info(
`rebuildClientAssociations: Client ${client.clientId} has ${totalSitesOnClient} sites so skipping adding peer to newt and olm because it is likely in jit mode`
);
continue;
}
await initPeerAddHandshake(
// this will kick off the add peer process for the client
client.clientId,
@@ -1245,9 +1336,24 @@ async function handleMessagesForClientSites(
);
}
await Promise.all(exitNodeJobs);
await Promise.all(newtJobs);
await Promise.all(olmJobs);
Promise.all(exitNodeJobs).catch((error) => {
logger.error(
`rebuildClientAssociations: Error updating client site destinations for client ${client.clientId}:`,
error
);
});
Promise.all(newtJobs).catch((error) => {
logger.error(
`rebuildClientAssociations: Error updating Newt peers for client ${client.clientId}:`,
error
);
});
Promise.all(olmJobs).catch((error) => {
logger.error(
`rebuildClientAssociations: Error updating Olm peers for client ${client.clientId}:`,
error
);
});
}
async function handleMessagesForClientResources(
@@ -1528,3 +1634,195 @@ async function handleMessagesForClientResources(
await Promise.all([...proxyJobs, ...olmJobs]);
}
export type ClientAssociationsCacheVerification = {
clientId: number;
consistent: boolean;
// What permissions say the cache should contain
expectedSiteResourceIds: number[];
expectedSiteIds: number[];
// What the cache currently contains
actualSiteResourceIds: number[];
actualSiteIds: number[];
// Diff
missingSiteResourceIds: number[]; // present in expected, missing from cache
extraSiteResourceIds: number[]; // present in cache, not in expected
missingSiteIds: number[];
extraSiteIds: number[];
};
// verifyClientAssociationsCache walks the same permission-derivation logic as
// rebuildClientAssociationsFromClient but does NOT modify the database. It
// returns the expected vs actual cache contents and a boolean indicating
// whether the cache is in sync with what permissions imply.
export async function verifyClientAssociationsCache(
client: Client,
trx: Transaction | typeof db = db
): Promise<ClientAssociationsCacheVerification> {
let newSiteResourceIds: number[] = [];
// 1. Direct client associations
const directSiteResources = await trx
.select({ siteResourceId: clientSiteResources.siteResourceId })
.from(clientSiteResources)
.innerJoin(
siteResources,
eq(siteResources.siteResourceId, clientSiteResources.siteResourceId)
)
.where(
and(
eq(clientSiteResources.clientId, client.clientId),
eq(siteResources.orgId, client.orgId)
)
);
newSiteResourceIds.push(
...directSiteResources.map((r) => r.siteResourceId)
);
// 2. User-based and role-based access (if client has a userId)
if (client.userId) {
const userSiteResourceIds = await trx
.select({ siteResourceId: userSiteResources.siteResourceId })
.from(userSiteResources)
.innerJoin(
siteResources,
eq(
siteResources.siteResourceId,
userSiteResources.siteResourceId
)
)
.where(
and(
eq(userSiteResources.userId, client.userId),
eq(siteResources.orgId, client.orgId)
)
);
newSiteResourceIds.push(
...userSiteResourceIds.map((r) => r.siteResourceId)
);
const roleIds = await trx
.select({ roleId: userOrgRoles.roleId })
.from(userOrgRoles)
.where(
and(
eq(userOrgRoles.userId, client.userId),
eq(userOrgRoles.orgId, client.orgId)
)
)
.then((rows) => rows.map((row) => row.roleId));
if (roleIds.length > 0) {
const roleSiteResourceIds = await trx
.select({ siteResourceId: roleSiteResources.siteResourceId })
.from(roleSiteResources)
.innerJoin(
siteResources,
eq(
siteResources.siteResourceId,
roleSiteResources.siteResourceId
)
)
.where(
and(
inArray(roleSiteResources.roleId, roleIds),
eq(siteResources.orgId, client.orgId)
)
);
newSiteResourceIds.push(
...roleSiteResourceIds.map((r) => r.siteResourceId)
);
}
}
newSiteResourceIds = Array.from(new Set(newSiteResourceIds));
const newSiteResources =
newSiteResourceIds.length > 0
? await trx
.select()
.from(siteResources)
.where(
inArray(siteResources.siteResourceId, newSiteResourceIds)
)
: [];
const networkIds = Array.from(
new Set(
newSiteResources
.map((sr) => sr.networkId)
.filter((id): id is number => id !== null)
)
);
const newSiteIds =
networkIds.length > 0
? await trx
.select({ siteId: siteNetworks.siteId })
.from(siteNetworks)
.where(inArray(siteNetworks.networkId, networkIds))
.then((rows) =>
Array.from(new Set(rows.map((r) => r.siteId)))
)
: [];
// Read the existing cache state
const existingResourceAssociations = await trx
.select({
siteResourceId: clientSiteResourcesAssociationsCache.siteResourceId
})
.from(clientSiteResourcesAssociationsCache)
.where(
eq(clientSiteResourcesAssociationsCache.clientId, client.clientId)
);
const existingSiteResourceIds = existingResourceAssociations.map(
(r) => r.siteResourceId
);
const existingSiteAssociations = await trx
.select({ siteId: clientSitesAssociationsCache.siteId })
.from(clientSitesAssociationsCache)
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
const existingSiteIds = existingSiteAssociations.map((s) => s.siteId);
const expectedSiteResourceSet = new Set(newSiteResourceIds);
const actualSiteResourceSet = new Set(existingSiteResourceIds);
const expectedSiteSet = new Set(newSiteIds);
const actualSiteSet = new Set(existingSiteIds);
const missingSiteResourceIds = newSiteResourceIds.filter(
(id) => !actualSiteResourceSet.has(id)
);
const extraSiteResourceIds = existingSiteResourceIds.filter(
(id) => !expectedSiteResourceSet.has(id)
);
const missingSiteIds = newSiteIds.filter((id) => !actualSiteSet.has(id));
const extraSiteIds = existingSiteIds.filter(
(id) => !expectedSiteSet.has(id)
);
const consistent =
missingSiteResourceIds.length === 0 &&
extraSiteResourceIds.length === 0 &&
missingSiteIds.length === 0 &&
extraSiteIds.length === 0;
return {
clientId: client.clientId,
consistent,
expectedSiteResourceIds: Array.from(expectedSiteResourceSet).sort(
(a, b) => a - b
),
expectedSiteIds: Array.from(expectedSiteSet).sort((a, b) => a - b),
actualSiteResourceIds: Array.from(actualSiteResourceSet).sort(
(a, b) => a - b
),
actualSiteIds: Array.from(actualSiteSet).sort((a, b) => a - b),
missingSiteResourceIds: missingSiteResourceIds.sort((a, b) => a - b),
extraSiteResourceIds: extraSiteResourceIds.sort((a, b) => a - b),
missingSiteIds: missingSiteIds.sort((a, b) => a - b),
extraSiteIds: extraSiteIds.sort((a, b) => a - b)
};
}

View File

@@ -31,6 +31,7 @@ import * as siteProvisioning from "#private/routers/siteProvisioning";
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
import * as alertRule from "#private/routers/alertRule";
import * as healthChecks from "#private/routers/healthChecks";
import * as client from "@server/routers/client";
import {
verifyOrgAccess,
@@ -775,3 +776,15 @@ authenticated.get(
verifyUserHasAction(ActionsEnum.getTarget),
healthChecks.getHealthCheckStatusHistory
);
authenticated.get(
"/client/:clientId/verify-associations-cache",
verifyClientAccess,
client.verifyClientAssociationsCache
);
authenticated.post(
"/client/:clientId/rebuild-associations-cache",
verifyClientAccess,
client.rebuildClientAssociationsCacheRoute
);

View File

@@ -26,7 +26,6 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { eq, InferInsertModel } from "drizzle-orm";
import { build } from "@server/build";
import { validateLocalPath } from "@app/lib/validateLocalPath";
import config from "#private/lib/config";
const paramsSchema = z.strictObject({
@@ -35,78 +34,9 @@ const paramsSchema = z.strictObject({
const bodySchema = z.strictObject({
logoUrl: z
.union([
z.literal(""),
z
.string()
.superRefine(async (urlOrPath, ctx) => {
const parseResult = z.url().safeParse(urlOrPath);
if (!parseResult.success) {
if (build !== "enterprise") {
ctx.addIssue({
code: "custom",
message: "Must be a valid URL"
});
return;
} else {
try {
validateLocalPath(urlOrPath);
} catch (error) {
ctx.addIssue({
code: "custom",
message: "Must be either a valid image URL or a valid pathname starting with `/` and not containing query parameters, `..` or `*`"
});
} finally {
return;
}
}
}
try {
const response = await fetch(urlOrPath, {
method: "HEAD"
}).catch(() => {
// If HEAD fails (CORS or method not allowed), try GET
return fetch(urlOrPath, { method: "GET" });
});
if (response.status !== 200) {
ctx.addIssue({
code: "custom",
message: `Failed to load image. Please check that the URL is accessible.`
});
return;
}
const contentType =
response.headers.get("content-type") ?? "";
if (!contentType.startsWith("image/")) {
ctx.addIssue({
code: "custom",
message: `URL does not point to an image. Please provide a URL to an image file (e.g., .png, .jpg, .svg).`
});
return;
}
} catch (error) {
let errorMessage =
"Unable to verify image URL. Please check that the URL is accessible and points to an image file.";
if (error instanceof TypeError && error.message.includes("fetch")) {
errorMessage =
"Network error: Unable to reach the URL. Please check your internet connection and verify the URL is correct.";
} else if (error instanceof Error) {
errorMessage = `Error verifying URL: ${error.message}`;
}
ctx.addIssue({
code: "custom",
message: errorMessage
});
}
})
])
.transform((val) => (val === "" ? null : val))
.nullish(),
.string()
.optional()
.transform((val) => (val === "" ? null : val)),
logoWidth: z.coerce.number<number>().min(1),
logoHeight: z.coerce.number<number>().min(1),
resourceTitle: z.string(),

View File

@@ -19,6 +19,7 @@ import {
logsDb,
newts,
roles,
roleSiteResources,
roundTripMessageTracker,
siteResources,
siteNetworks,
@@ -361,9 +362,26 @@ export async function signSshKey(
}
const roleRows = await db
.select()
.select({
sshSudoCommands: roles.sshSudoCommands,
sshUnixGroups: roles.sshUnixGroups,
sshCreateHomeDir: roles.sshCreateHomeDir,
sshSudoMode: roles.sshSudoMode
})
.from(roles)
.where(inArray(roles.roleId, roleIds));
.innerJoin(
roleSiteResources,
eq(roleSiteResources.roleId, roles.roleId)
)
.where(
and(
inArray(roles.roleId, roleIds),
eq(
roleSiteResources.siteResourceId,
resource.siteResourceId
)
)
);
const parsedSudoCommands: string[] = [];
const parsedGroupsSet = new Set<string>();
@@ -379,13 +397,17 @@ export async function signSshKey(
}
try {
const grps = JSON.parse(roleRow?.sshUnixGroups ?? "[]");
if (Array.isArray(grps)) grps.forEach((g: string) => parsedGroupsSet.add(g));
if (Array.isArray(grps))
grps.forEach((g: string) => parsedGroupsSet.add(g));
} catch {
// skip
}
if (roleRow?.sshCreateHomeDir === true) homedir = true;
const m = roleRow?.sshSudoMode ?? "none";
if (sudoModeOrder[m as keyof typeof sudoModeOrder] > sudoModeOrder[sudoMode]) {
if (
sudoModeOrder[m as keyof typeof sudoModeOrder] >
sudoModeOrder[sudoMode]
) {
sudoMode = m as "none" | "commands" | "full";
}
}

View File

@@ -10,3 +10,5 @@ export * from "./listUserDevices";
export * from "./updateClient";
export * from "./getClient";
export * from "./createUserClient";
export * from "./verifyClientAssociationsCache";
export * from "./rebuildClientAssociationsCacheRoute";

View File

@@ -0,0 +1,81 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { clients } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
const paramsSchema = z.strictObject({
clientId: z.string().transform(Number).pipe(z.int().positive())
});
registry.registerPath({
method: "post",
path: "/client/{clientId}/rebuild-associations-cache",
description:
"Rebuild the client's site/site-resource association cache based on current permissions.",
tags: [OpenAPITags.Client],
request: {
params: paramsSchema
},
responses: {}
});
export async function rebuildClientAssociationsCacheRoute(
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()
)
);
}
const { clientId } = parsedParams.data;
const [client] = await db
.select()
.from(clients)
.where(eq(clients.clientId, clientId))
.limit(1);
if (!client) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Client with ID ${clientId} not found`
)
);
}
await rebuildClientAssociationsFromClient(client);
return response(res, {
data: null,
success: true,
error: false,
message: "Client association cache rebuilt successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to rebuild client association cache"
)
);
}
}

View File

@@ -0,0 +1,83 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { clients } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { verifyClientAssociationsCache as verifyClientAssociationsCacheLib } from "@server/lib/rebuildClientAssociations";
const paramsSchema = z.strictObject({
clientId: z.string().transform(Number).pipe(z.int().positive())
});
registry.registerPath({
method: "get",
path: "/client/{clientId}/verify-associations-cache",
description:
"Read-only check of whether the client's site/site-resource association cache matches what the current permissions imply.",
tags: [OpenAPITags.Client],
request: {
params: paramsSchema
},
responses: {}
});
export async function verifyClientAssociationsCache(
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()
)
);
}
const { clientId } = parsedParams.data;
const [client] = await db
.select()
.from(clients)
.where(eq(clients.clientId, clientId))
.limit(1);
if (!client) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Client with ID ${clientId} not found`
)
);
}
const report = await verifyClientAssociationsCacheLib(client);
return response(res, {
data: report,
success: true,
error: false,
message: report.consistent
? "Client association cache is consistent"
: "Client association cache is INCONSISTENT",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to verify client association cache"
)
);
}
}

View File

@@ -153,6 +153,65 @@ export default function GeneralPage() {
const [approvalId, setApprovalId] = useState<number | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const [, startTransition] = useTransition();
const [cacheCheck, setCacheCheck] = useState<null | {
consistent: boolean;
missingSiteResourceIds: number[];
extraSiteResourceIds: number[];
missingSiteIds: number[];
extraSiteIds: number[];
expectedSiteResourceIds: number[];
actualSiteResourceIds: number[];
expectedSiteIds: number[];
actualSiteIds: number[];
}>(null);
const [isCheckingCache, setIsCheckingCache] = useState(false);
const [isRebuildingCache, setIsRebuildingCache] = useState(false);
const handleRebuildCache = async () => {
if (!client.clientId) return;
setIsRebuildingCache(true);
try {
await api.post(
`/client/${client.clientId}/rebuild-associations-cache`
);
// Re-verify after rebuild so the result refreshes
const res = await api.get(
`/client/${client.clientId}/verify-associations-cache`
);
setCacheCheck(res.data.data);
toast({
title: "Cache rebuilt",
description: "Association cache rebuilt successfully."
});
} catch (e) {
toast({
variant: "destructive",
title: "Rebuild failed",
description: formatAxiosError(e, "Failed to rebuild cache")
});
} finally {
setIsRebuildingCache(false);
}
};
const handleVerifyCache = async () => {
if (!client.clientId) return;
setIsCheckingCache(true);
try {
const res = await api.get(
`/client/${client.clientId}/verify-associations-cache`
);
setCacheCheck(res.data.data);
} catch (e) {
toast({
variant: "destructive",
title: "Cache check failed",
description: formatAxiosError(e, "Failed to verify cache")
});
} finally {
setIsCheckingCache(false);
}
};
const { env } = useEnvContext();
const showApprovalFeatures =
@@ -844,6 +903,75 @@ export default function GeneralPage() {
</SettingsSectionBody>
</SettingsSection>
)}
{/* Hidden cache verification — subtle button, dev/admin diagnostic */}
<div className="mt-8 flex flex-col gap-2 items-start opacity-30 hover:opacity-100 transition-opacity">
<button
type="button"
onClick={handleVerifyCache}
disabled={isCheckingCache}
className="text-xs text-muted-foreground underline disabled:opacity-50"
title="Verify the client's site association cache against current permissions (read-only)"
>
{isCheckingCache
? "Checking cache…"
: "Verify association cache"}
</button>
{cacheCheck && (
<div
className={
"text-xs rounded border px-2 py-1 " +
(cacheCheck.consistent
? "border-green-600 text-green-700"
: "border-red-600 text-red-700")
}
>
{cacheCheck.consistent ? (
<span className="flex items-center gap-1">
<CheckCircle2 className="h-3 w-3" />
Cache is consistent
</span>
) : (
<div className="space-y-2">
<div className="flex items-center gap-1 font-semibold">
<XCircle className="h-3 w-3" />
Cache is INCONSISTENT
</div>
<div>
Missing site resources: [
{cacheCheck.missingSiteResourceIds.join(
", "
)}
]
</div>
<div>
Extra site resources: [
{cacheCheck.extraSiteResourceIds.join(", ")}
]
</div>
<div>
Missing sites: [
{cacheCheck.missingSiteIds.join(", ")}]
</div>
<div>
Extra sites: [
{cacheCheck.extraSiteIds.join(", ")}]
</div>
<button
type="button"
onClick={handleRebuildCache}
disabled={isRebuildingCache}
className="mt-1 text-xs underline font-semibold disabled:opacity-50"
>
{isRebuildingCache
? "Rebuilding…"
: "Rebuild cache now"}
</button>
</div>
)}
</div>
)}
</div>
</SettingsContainer>
);
}

View File

@@ -44,77 +44,11 @@ export type AuthPageCustomizationProps = {
};
const AuthPageFormSchema = z.object({
logoUrl: z.union([
z.literal(""),
z.string().superRefine(async (urlOrPath, ctx) => {
const parseResult = z.url().safeParse(urlOrPath);
if (!parseResult.success) {
if (build !== "enterprise") {
ctx.addIssue({
code: "custom",
message: "Must be a valid URL"
});
return;
} else {
try {
validateLocalPath(urlOrPath);
} catch (error) {
ctx.addIssue({
code: "custom",
message:
"Must be either a valid image URL or a valid pathname starting with `/` and not containing query parameters, `..` or `*`"
});
} finally {
return;
}
}
}
logoUrl: z
.string()
.optional()
.transform((val) => (val === "" ? undefined : val)),
try {
const response = await fetch(urlOrPath, {
method: "HEAD"
}).catch(() => {
// If HEAD fails (CORS or method not allowed), try GET
return fetch(urlOrPath, { method: "GET" });
});
if (response.status !== 200) {
ctx.addIssue({
code: "custom",
message: `Failed to load image. Please check that the URL is accessible.`
});
return;
}
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.startsWith("image/")) {
ctx.addIssue({
code: "custom",
message: `URL does not point to an image. Please provide a URL to an image file (e.g., .png, .jpg, .svg).`
});
return;
}
} catch (error) {
let errorMessage =
"Unable to verify image URL. Please check that the URL is accessible and points to an image file.";
if (
error instanceof TypeError &&
error.message.includes("fetch")
) {
errorMessage =
"Network error: Unable to reach the URL. Please check your internet connection and verify the URL is correct.";
} else if (error instanceof Error) {
errorMessage = `Error verifying URL: ${error.message}`;
}
ctx.addIssue({
code: "custom",
message: errorMessage
});
}
})
]),
logoWidth: z.coerce.number<number>().min(1),
logoHeight: z.coerce.number<number>().min(1),
orgTitle: z.string().optional(),